diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 16a63e27f..427010ca9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -19,3 +19,11 @@ updates: - dependency-name: lxml versions: - 4.6.2 +- package-ecosystem: github-actions + directory: "/" + groups: + github-actions: + patterns: + - "*" # Group all Actions updates into a single larger pull request + schedule: + interval: daily diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28bfcc5ee..5bd57ab9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres:10.1 + image: postgres:15.3 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -16,7 +16,7 @@ jobs: options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - name: Check out repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Install platform dependencies run: | sudo apt -y update @@ -29,11 +29,11 @@ jobs: run: | wget https://github.com/jgm/pandoc/releases/download/2.17.1.1/pandoc-2.17.1.1-1-amd64.deb sudo dpkg -i pandoc-2.17.1.1-1-amd64.deb - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v5 with: python-version: 3.9.16 - name: Cache Python dependencies - uses: actions/cache@v2 + uses: actions/cache@v4 env: cache-name: pythondotorg-cache-pip with: diff --git a/README.md b/README.md index 97fa0341c..fc59b7cdf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # python.org -[![Build Status](https://travis-ci.org/python/pythondotorg.svg?branch=main)](https://travis-ci.org/python/pythondotorg) +[![CI](https://github.com/python/pythondotorg/actions/workflows/ci.yml/badge.svg)](https://github.com/python/pythondotorg/actions/workflows/ci.yml) [![Documentation Status](https://readthedocs.org/projects/pythondotorg/badge/?version=latest)](https://pythondotorg.readthedocs.io/?badge=latest) ### General information diff --git a/banners/__init__.py b/banners/__init__.py index 010b54570..e69de29bb 100644 --- a/banners/__init__.py +++ b/banners/__init__.py @@ -1 +0,0 @@ -default_app_config = 'banners.apps.BannersAppConfig' diff --git a/base-requirements.txt b/base-requirements.txt index bee3181b7..53c4945b7 100644 --- a/base-requirements.txt +++ b/base-requirements.txt @@ -1,10 +1,10 @@ dj-database-url==0.5.0 -django-pipeline==2.0.6 -django-sitetree==1.17.0 +django-pipeline==3.0.0 # 3.0.0 is first version that supports Django 4.2 +django-sitetree==1.18.0 # >=1.17.1 is (?) first version that supports Django 4.2 django-apptemplates==1.5 django-admin-interface==0.24.2 django-translation-aliases==0.1.0 -Django==2.2.24 +Django==4.2.16 docutils==0.12 Markdown==3.3.4 cmarkgfm==0.6.0 @@ -13,7 +13,7 @@ psycopg2-binary==2.8.6 python3-openid==3.2.0 python-decouple==3.4 # lxml used by BeautifulSoup. -lxml==4.9.2 +lxml==5.2.2 cssselect==1.1.0 feedparser==6.0.8 beautifulsoup4==4.11.2 @@ -22,35 +22,35 @@ chardet==4.0.0 celery[redis]==5.3.6 django-celery-beat==2.5.0 # TODO: We may drop 'django-imagekit' completely. -django-imagekit==4.0.2 +django-imagekit==5.0 # 5.0 is first version that supports Django 4.2 django-haystack==3.2.1 elasticsearch>=7,<8 # TODO: 0.14.0 only supports Django 1.8 and 1.11. -django-tastypie==0.14.3 +django-tastypie==0.14.6 # 0.14.6 is first version that supports Django 4.2 pytz==2021.1 python-dateutil==2.8.2 requests[security]>=2.26.0 -django-honeypot==1.0.1 -django-markupfield==2.0.0 -django-markupfield-helpers==0.1.1 +django-honeypot==1.0.4 # 1.0.4 is first version that supports Django 4.2 +django-markupfield==2.0.1 -django-allauth==0.41.0 +django-allauth==0.57.2 # 0.55.0 is first version that supports Django 4.2 django-waffle==2.2.1 -djangorestframework==3.12.2 +djangorestframework==3.14.0 # 3.14.0 is first version that supports Django 4.1, 4.2 support hasnt been "released" django-filter==2.4.0 django-ordered-model==3.4.3 django-widget-tweaks==1.4.8 django-countries==7.2.1 num2words==0.5.10 -django-polymorphic==3.0.0 +django-polymorphic==3.1.0 # 3.1.0 is first version that supports Django 4.0, unsure if it fully supports 4.2 sorl-thumbnail==12.7.0 django-extensions==3.1.4 django-import-export==2.7.1 pypandoc==1.12 panflute==2.3.0 +Unidecode==1.3.8 diff --git a/blogs/__init__.py b/blogs/__init__.py index 620291c46..e69de29bb 100644 --- a/blogs/__init__.py +++ b/blogs/__init__.py @@ -1 +0,0 @@ -default_app_config = 'blogs.apps.BlogsAppConfig' diff --git a/blogs/admin.py b/blogs/admin.py index 055431ae9..e5fea1cfb 100644 --- a/blogs/admin.py +++ b/blogs/admin.py @@ -10,11 +10,13 @@ class BlogEntryAdmin(admin.ModelAdmin): date_hierarchy = 'pub_date' actions = ['sync_new_entries'] + @admin.action( + description="Sync new blog entries" + ) def sync_new_entries(self, request, queryset): call_command('update_blogs') self.message_user(request, "Blog entries updated.") - sync_new_entries.short_description = "Sync new blog entries" @admin.register(FeedAggregate) diff --git a/blogs/migrations/0003_alter_relatedblog_creator_and_more.py b/blogs/migrations/0003_alter_relatedblog_creator_and_more.py new file mode 100644 index 000000000..9e71084a8 --- /dev/null +++ b/blogs/migrations/0003_alter_relatedblog_creator_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.11 on 2024-09-05 17:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('blogs', '0002_remove_translations_and_contributors'), + ] + + operations = [ + migrations.AlterField( + model_name='relatedblog', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='relatedblog', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/blogs/parser.py b/blogs/parser.py index 8ac8dc684..fd5e4b54d 100644 --- a/blogs/parser.py +++ b/blogs/parser.py @@ -3,7 +3,7 @@ from django.conf import settings from django.template.loader import render_to_string -from django.utils.timezone import make_aware, utc +from django.utils.timezone import make_aware from boxes.models import Box from .models import BlogEntry, Feed @@ -16,7 +16,7 @@ def get_all_entries(feed_url): for e in d['entries']: published = make_aware( - datetime.datetime(*e['published_parsed'][:7]), timezone=utc + datetime.datetime(*e['published_parsed'][:7]), timezone=datetime.timezone.utc ) entry = { diff --git a/boxes/__init__.py b/boxes/__init__.py index 401a83d2e..e69de29bb 100644 --- a/boxes/__init__.py +++ b/boxes/__init__.py @@ -1 +0,0 @@ -default_app_config = 'boxes.apps.BoxesAppConfig' diff --git a/boxes/migrations/0004_alter_box_creator_alter_box_last_modified_by.py b/boxes/migrations/0004_alter_box_creator_alter_box_last_modified_by.py new file mode 100644 index 000000000..3829382ec --- /dev/null +++ b/boxes/migrations/0004_alter_box_creator_alter_box_last_modified_by.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.11 on 2024-09-05 17:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('boxes', '0003_auto_20171101_2138'), + ] + + operations = [ + migrations.AlterField( + model_name='box', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='box', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/cms/__init__.py b/cms/__init__.py index 92d29195c..e69de29bb 100644 --- a/cms/__init__.py +++ b/cms/__init__.py @@ -1 +0,0 @@ -default_app_config = 'cms.apps.CmsAppConfig' diff --git a/codesamples/__init__.py b/codesamples/__init__.py index f51a992fa..e69de29bb 100644 --- a/codesamples/__init__.py +++ b/codesamples/__init__.py @@ -1 +0,0 @@ -default_app_config = 'codesamples.apps.CodesamplesAppConfig' diff --git a/codesamples/migrations/0004_alter_codesample_creator_and_more.py b/codesamples/migrations/0004_alter_codesample_creator_and_more.py new file mode 100644 index 000000000..0b29294ad --- /dev/null +++ b/codesamples/migrations/0004_alter_codesample_creator_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.11 on 2024-09-05 17:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('codesamples', '0003_auto_20170821_2000'), + ] + + operations = [ + migrations.AlterField( + model_name='codesample', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='codesample', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/codesamples/tests.py b/codesamples/tests.py index 73c85c164..7ddf51119 100644 --- a/codesamples/tests.py +++ b/codesamples/tests.py @@ -16,9 +16,7 @@ def setUp(self): is_published=False) def test_published(self): - self.assertQuerysetEqual(CodeSample.objects.published(), - ['']) + self.assertQuerySetEqual(CodeSample.objects.published(),[''], transform=repr) def test_draft(self): - self.assertQuerysetEqual(CodeSample.objects.draft(), - ['']) + self.assertQuerySetEqual(CodeSample.objects.draft(),[''], transform=repr) diff --git a/community/__init__.py b/community/__init__.py index bc11cfaf6..e69de29bb 100644 --- a/community/__init__.py +++ b/community/__init__.py @@ -1 +0,0 @@ -default_app_config = 'community.apps.CommunityAppConfig' diff --git a/community/migrations/0005_alter_link_creator_alter_link_last_modified_by_and_more.py b/community/migrations/0005_alter_link_creator_alter_link_last_modified_by_and_more.py new file mode 100644 index 000000000..9372dbf0e --- /dev/null +++ b/community/migrations/0005_alter_link_creator_alter_link_last_modified_by_and_more.py @@ -0,0 +1,76 @@ +# Generated by Django 4.2.11 on 2024-09-05 17:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('community', '0001_squashed_0004_auto_20170831_0541'), + ] + + operations = [ + migrations.AlterField( + model_name='link', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='link', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='link', + name='post', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='related_%(class)s', to='community.post'), + ), + migrations.AlterField( + model_name='photo', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='photo', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='photo', + name='post', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='related_%(class)s', to='community.post'), + ), + migrations.AlterField( + model_name='post', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='post', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='post', + name='meta', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AlterField( + model_name='video', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='video', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='video', + name='post', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='related_%(class)s', to='community.post'), + ), + ] diff --git a/community/models.py b/community/models.py index 1e199b590..75ee94cd8 100644 --- a/community/models.py +++ b/community/models.py @@ -1,4 +1,4 @@ -from django.contrib.postgres.fields import JSONField +from django.db.models import JSONField from django.urls import reverse from django.db import models from django.utils.translation import gettext_lazy as _ diff --git a/community/tests/test_managers.py b/community/tests/test_managers.py index 004e5ee2e..8e91e5523 100644 --- a/community/tests/test_managers.py +++ b/community/tests/test_managers.py @@ -16,6 +16,6 @@ def test_post_manager(self): status=Post.STATUS_PUBLIC ) - self.assertQuerysetEqual(Post.objects.all(), [public_post, private_post], lambda x: x) - self.assertQuerysetEqual(Post.objects.public(), [public_post], lambda x: x) - self.assertQuerysetEqual(Post.objects.private(), [private_post], lambda x: x) + self.assertQuerySetEqual(Post.objects.all(), [public_post, private_post], lambda x: x) + self.assertQuerySetEqual(Post.objects.public(), [public_post], lambda x: x) + self.assertQuerySetEqual(Post.objects.private(), [private_post], lambda x: x) diff --git a/companies/__init__.py b/companies/__init__.py index 1a15cc943..e69de29bb 100644 --- a/companies/__init__.py +++ b/companies/__init__.py @@ -1 +0,0 @@ -default_app_config = 'companies.apps.CompaniesAppConfig' diff --git a/config/nginx.conf b/config/nginx.conf index 5ee6aaf6b..420fcd8af 100644 --- a/config/nginx.conf +++ b/config/nginx.conf @@ -50,10 +50,6 @@ http { return 301 http://www.python.org/psf; } - location /psf/codeofconduct { - return 301 /psf/conduct; - } - location /topics/xml { return 301 http://pyxml.sourceforge.net/topics; } @@ -314,6 +310,22 @@ http { return 301 https://python.org/blogs/; } + location ~ ^/psf/archive/codeofconduct/?$ { + return 302 https://policies.python.org/python.org/code-of-conduct/; + } + location ~ ^/psf/codeofconduct/?$ { + return 302 https://policies.python.org/python.org/code-of-conduct/; + } + location ~ ^/psf/conduct/?$ { + return 302 https://policies.python.org/python.org/code-of-conduct/; + } + location ~ ^/psf/conduct/enforcement/?$ { + return 302 https://policies.python.org/python.org/code-of-conduct/Enforcement-Procedures/; + } + location ~ ^/psf/conduct/reporting/?$ { + return 302 https://policies.python.org/python.org/code-of-conduct/Procedures-for-Reporting-Incidents/; + } + location /static/ { alias /code/static-root/; add_header Cache-Control "max-age=604800, public"; # 604800 is 7 days diff --git a/dev-requirements.txt b/dev-requirements.txt index 9b5e0938f..8d61d0f9d 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,7 +2,7 @@ # Required for running tests -factory-boy==3.1.0 +factory-boy==3.2.1 Faker==0.8.1 tblib==1.7.0 responses==0.13.3 diff --git a/docker-compose.yml b/docker-compose.yml index 2e5f8bf16..4406c0ff5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.9" services: postgres: - image: postgres:10-bullseye + image: postgres:15.3-bullseye ports: - "5433:5432" environment: diff --git a/docs/source/administration.rst b/docs/source/administration.rst index 3000ff692..872222055 100644 --- a/docs/source/administration.rst +++ b/docs/source/administration.rst @@ -6,7 +6,7 @@ Administration Navigation ---------- -Navigation on the site is managed by the `Sitetree `_ application. The hierarchy should be fairly obvious. The biggest gotcha is when defining the URLs. +Navigation on the site is managed by the `Sitetree `_ application. The hierarchy should be fairly obvious. The biggest gotcha is when defining the URLs. Many URLs are defined using `Django's URL system `_ however many are also simply defined as relative paths. When editing a particular item in the Sitetree in the *Additional Settings* fieldset there is an option named *URL as pattern*. If this option is checked the URL pattern is checked against the URLs defined by the Django applications. If it is left unchecked relative and absolute URLs can be entered. diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst index 66a4d0553..8b0cbd5e0 100644 --- a/docs/source/contributing.rst +++ b/docs/source/contributing.rst @@ -11,7 +11,7 @@ already submitted it. Code ---- -The source for python.org is open and licensed under the `Apache 2 license `_. +The source for python.org is open and licensed under the `Apache 2 license `_. To contribute to either the code or documentation please fork the pythondotorg_ repository and submit a pull request. diff --git a/docs/source/index.rst b/docs/source/index.rst index 6de5d915b..76a201828 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,9 +12,9 @@ General information :IRC: ``#pydotorg`` on Freenode :Staging site: https://staging.python.org/ (``main`` branch) :Production configuration: https://github.com/python/psf-salt -:Travis: - .. image:: https://travis-ci.org/python/pythondotorg.svg?branch=main - :target: https://travis-ci.org/python/pythondotorg +:GitHub Actions: + .. image:: https://github.com/python/pythondotorg/actions/workflows/ci.yml/badge.svg + :target: https://github.com/python/pythondotorg/actions/workflows/ci.yml :License: Apache License Contents: diff --git a/docs/source/install.md b/docs/source/install.md index 5639e144d..d6c29d295 100644 --- a/docs/source/install.md +++ b/docs/source/install.md @@ -12,13 +12,13 @@ Docker Compose will be installed by [Docker Mac](https://docs.docker.com/desktop Getting started --------------- -To get the Pythondotorg source code, [fork](https://docs.github.com/en/get-started/quickstart/fork-a-repo) the repository on [GitHub](https://github.com/python/pythondotorg) and [clone](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) it to your local machine: +To get the Pythondotorg source code, [fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) the repository on [GitHub](https://github.com/python/pythondotorg) and [clone](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) it to your local machine: ``` git clone git@github.com:YOUR-USERNAME/pythondotorg.git ``` -Add a [remote](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/configuring-a-remote-for-a-fork) and [sync](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) regularly to stay current with the repository. +Add a [remote](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/configuring-a-remote-repository-for-a-fork) and [sync](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) regularly to stay current with the repository. ``` git remote add upstream https://github.com/python/pythondotorg @@ -33,7 +33,7 @@ Installing Docker Install [Docker Engine](https://docs.docker.com/engine/install/) ```{note} -The best experience for building Pythondotorg on Windows is to use the [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/)(WSL) in combination with both [Docker for Windows](https://docs.docker.com/desktop/install/windows-install/) and [Docker for Linux](https://docs.docker.com/engine/install/). +The best experience for building Pythondotorg on Windows is to use the [Windows Subsystem for Linux](https://learn.microsoft.com/en-us/windows/wsl/)(WSL) in combination with both [Docker for Windows](https://docs.docker.com/desktop/install/windows-install/) and [Docker for Linux](https://docs.docker.com/engine/install/). ``` Verify that the Docker installation is successful by running: `docker -v` @@ -197,7 +197,7 @@ Once you have it installed, update the URL value of `HAYSTACK_CONNECTIONS` set Generating CSS files automatically ---------------------------------- -Due to performance issues of [django-pipeline](https://github.com/cyberdelia/django-pipeline/issues/313), we are using a dummy compiler `pydotorg.compilers.DummySASSCompiler` in development mode. To generate CSS files, use `sass` itself in a separate terminal window: +Due to performance issues of [django-pipeline](https://github.com/jazzband/django-pipeline/issues/313), we are using a dummy compiler `pydotorg.compilers.DummySASSCompiler` in development mode. To generate CSS files, use `sass` itself in a separate terminal window: ``` $ cd static diff --git a/docs/source/pep_generation.rst b/docs/source/pep_generation.rst index 3ea1bb363..dd68649af 100644 --- a/docs/source/pep_generation.rst +++ b/docs/source/pep_generation.rst @@ -31,4 +31,4 @@ This process runs periodically via cron to keep the PEP pages up to date. See :ref:`management-commands` for all management commands. -.. _PEP Repository: https://github.com/python/peps.git \ No newline at end of file +.. _PEP Repository: https://github.com/python/peps diff --git a/downloads/__init__.py b/downloads/__init__.py index 0f460f952..e69de29bb 100644 --- a/downloads/__init__.py +++ b/downloads/__init__.py @@ -1 +0,0 @@ -default_app_config = 'downloads.apps.DownloadsAppConfig' diff --git a/downloads/migrations/0011_alter_os_creator_alter_os_last_modified_by_and_more.py b/downloads/migrations/0011_alter_os_creator_alter_os_last_modified_by_and_more.py new file mode 100644 index 000000000..368d575c2 --- /dev/null +++ b/downloads/migrations/0011_alter_os_creator_alter_os_last_modified_by_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 4.2.11 on 2024-09-05 17:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('downloads', '0010_releasefile_sbom_spdx2_file'), + ] + + operations = [ + migrations.AlterField( + model_name='os', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='os', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='release', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='release', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='releasefile', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='releasefile', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/downloads/tests/test_views.py b/downloads/tests/test_views.py index 50270c556..e495b9e93 100644 --- a/downloads/tests/test_views.py +++ b/downloads/tests/test_views.py @@ -41,7 +41,7 @@ def test_download_release_detail(self): self.assertEqual(response.status_code, 200) with self.subTest("Release file sizes should be human-readable"): - self.assertInHTML("11.8 MB", response.content.decode()) + self.assertInHTML("11.8 MB", response.content.decode()) url = reverse('download:download_release_detail', kwargs={'release_slug': 'fake_slug'}) response = self.client.get(url) @@ -122,7 +122,7 @@ def test_invalid_token(self): self.assertEqual(response.status_code, 401) url = self.create_url('os') - response = self.client.get(url, HTTP_AUTHORIZATION=self.Authorization_invalid) + response = self.client.get(url, headers={"authorization": self.Authorization_invalid}) # TODO: API v1 returns 200 for a GET request even if token is invalid. # 'StaffAuthorization.read_list` returns 'object_list' unconditionally, # and 'StaffAuthorization.read_detail` returns 'True'. @@ -222,7 +222,7 @@ def test_get_release(self): self.assertEqual(len(content), 4) # Login to get all releases. - response = self.client.get(url, HTTP_AUTHORIZATION=self.Authorization) + response = self.client.get(url, headers={"authorization": self.Authorization}) self.assertEqual(response.status_code, 200) content = self.get_json(response) self.assertEqual(len(content), 5) @@ -258,7 +258,7 @@ def test_post_release(self): response = self.client.get(new_url) # TODO: API v1 returns 401; and API v2 returns 404. self.assertIn(response.status_code, [401, 404]) - response = self.client.get(new_url, HTTP_AUTHORIZATION=self.Authorization) + response = self.client.get(new_url, headers={"authorization": self.Authorization}) self.assertEqual(response.status_code, 200) content = self.get_json(response) self.assertEqual(content['name'], data['name']) @@ -490,15 +490,15 @@ def test_throttling_anon(self): ) def test_throttling_user(self): url = self.create_url('os') - response = self.client.get(url, HTTP_AUTHORIZATION=self.Authorization) + response = self.client.get(url, headers={"authorization": self.Authorization}) self.assertEqual(response.status_code, 200) # Second request should be okay for a user. - response = self.client.get(url, HTTP_AUTHORIZATION=self.Authorization) + response = self.client.get(url, headers={"authorization": self.Authorization}) self.assertEqual(response.status_code, 200) # Third request should return '429 TOO MANY REQUESTS'. - response = self.client.get(url, HTTP_AUTHORIZATION=self.Authorization) + response = self.client.get(url, headers={"authorization": self.Authorization}) self.assertEqual(response.status_code, 429) def test_filter_release_file_delete_by_release(self): @@ -552,6 +552,6 @@ def test_filter_release_file_delete_by_release(self): 'release_file/delete_by_release', filters={'release': self.release_275.pk}, ), - HTTP_AUTHORIZATION=self.Authorization, + headers={"authorization": self.Authorization} ) self.assertEqual(response.status_code, 405) diff --git a/events/__init__.py b/events/__init__.py index 28291bff9..e69de29bb 100644 --- a/events/__init__.py +++ b/events/__init__.py @@ -1 +0,0 @@ -default_app_config = 'events.apps.EventsAppConfig' diff --git a/events/importer.py b/events/importer.py index fa303184b..e47775060 100644 --- a/events/importer.py +++ b/events/importer.py @@ -48,12 +48,13 @@ def import_event(self, event_data): ) defaults = { 'title': title, - 'description': description, - 'description_markup_type': 'html', 'venue': location, 'calendar': self.calendar, } event, _ = Event.objects.update_or_create(uid=uid, defaults=defaults) + event.description.raw = description + event.description.markup_type = "html" + event.save() self.import_occurrence(event, event_data) def fetch(self, url): diff --git a/events/migrations/0008_alter_alarm_creator_alter_alarm_last_modified_by_and_more.py b/events/migrations/0008_alter_alarm_creator_alter_alarm_last_modified_by_and_more.py new file mode 100644 index 000000000..371ae3aae --- /dev/null +++ b/events/migrations/0008_alter_alarm_creator_alter_alarm_last_modified_by_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 4.2.11 on 2024-09-05 17:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('events', '0007_auto_20180705_0352'), + ] + + operations = [ + migrations.AlterField( + model_name='alarm', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='alarm', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='calendar', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='calendar', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='event', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='event', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/infra/.gitignore b/infra/.gitignore new file mode 100644 index 000000000..e61ff928d --- /dev/null +++ b/infra/.gitignore @@ -0,0 +1,15 @@ + +**/.terraform/* +*.tfstate +*.tfstate.* +crash.log +crash.*.log +*.tfvars +*.tfvars.json +override.tf +override.tf.json +*_override.tf +*_override.tf.json +.terraform.tfstate.lock.info +.terraformrc +terraform.rc \ No newline at end of file diff --git a/infra/.terraform.lock.hcl b/infra/.terraform.lock.hcl new file mode 100644 index 000000000..165cd9357 --- /dev/null +++ b/infra/.terraform.lock.hcl @@ -0,0 +1,24 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/fastly/fastly" { + version = "5.13.0" + constraints = ">= 5.13.0" + hashes = [ + "h1:op/7hntTRkfZFIZ5xLNtLb7eBY155ywQIVSy56XCmBE=", + "zh:04f7405ee22a8ace546b90cc3a08d81f1a49dae8b1050500398d4b0244dcbc86", + "zh:0e0c48aca34a1fc7ed7382c8e85b5da770f63f3c9aa79bc2c3c55ed570f9d0ab", + "zh:302d2b9872ab8ffee2082291cc2cfec487633e22c7970b2c9d22268d6b5f7624", + "zh:346ea021dbe2c7128cddc2c9e01a95242b8bceeda20d9d1b00ae09ee90e3962b", + "zh:41fbe18f63154a6a1a46e1b1cc909bfe90f5bba7f5cfab0a80d15be7eceec4c3", + "zh:524c2a54282a92d0d7633bfd511427f6d9aa6b6a52b7d9f71cf5206dedab381e", + "zh:721fe08bfb1b85f8946aeba3bdb7e0de3d74fce94c8657d0086b153f58558d89", + "zh:9c627b3170a5505c73455e6c2a99d2ce4187e225130e12aececdc808357f8b66", + "zh:a61a62cec9612358b08ef1895277a37d4d4ec134972991fa414255ef95683dba", + "zh:bde1a51553c15d333140c2b77481ee668c4af8de93a968d869c02a736db460c4", + "zh:c2683862bd0e9633d3800503a71b3aab51ec8e3aac3f6ef6b71831efe81a2afd", + "zh:dff5ad3766432550974d2f0c24535c572fee5eeb0dce7befeaa97cb6ca3d8443", + "zh:ec3c56fc43344a07b0eef5158df6dd50e68bdcee1b03299bb2acd502d11582d5", + "zh:ec8d899cafd925d3492f00c6523c90599aebc43c1373ad4bd6c55f12d2376230", + ] +} diff --git a/infra/Makefile b/infra/Makefile new file mode 100644 index 000000000..bee74cdc1 --- /dev/null +++ b/infra/Makefile @@ -0,0 +1,7 @@ +.PHONY: fmt +fmt: + @terraform fmt ./**/*.tf + +.PHONY: check +check: + @terraform validate diff --git a/infra/cdn/README.md b/infra/cdn/README.md new file mode 100644 index 000000000..6ebe5a637 --- /dev/null +++ b/infra/cdn/README.md @@ -0,0 +1,33 @@ +# Fastly CDN Config + +This module creates Fastly services for the Python.org staging and production instances. + +## Usage + +```hcl +module "fastly_production" { + source = "./cdn" + + name = "CoolPythonApp.org" + domain = "CoolPythonApp.org" + subdomain = "www.CoolPythonApp.org" + extra_domains = ["www.CoolPythonApp.org"] + backend_address = "service.CoolPythonApp.org" + default_ttl = 3600 + + datadog_key = var.DATADOG_API_KEY + fastly_key = var.FASTLY_API_KEY + fastly_header_token = var.FASTLY_HEADER_TOKEN + s3_logging_keys = var.fastly_s3_logging +} +``` + +## Outputs + +N/A + +## Requirements + +Tested on +- Tested on Terraform 1.8.5 +- Fastly provider 5.13.0 \ No newline at end of file diff --git a/infra/cdn/certs/psf.io.pem b/infra/cdn/certs/psf.io.pem new file mode 100644 index 000000000..7952bb36b --- /dev/null +++ b/infra/cdn/certs/psf.io.pem @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIEQzCCAyugAwIBAgIUYH38nEb2KLRgscKhjcNpBLRUz+UwDQYJKoZIhvcNAQEL +BQAwgbAxCzAJBgNVBAYTAlVTMQ8wDQYDVQQIDAZPcmVnb24xEjAQBgNVBAcMCUJl +YXZlcnRvbjEjMCEGA1UECgwaUHl0aG9uIFNvZnR3YXJlIEZvdW5kYXRpb24xHDAa +BgNVBAsME0luZnJhc3RydWN0dXJlIFRlYW0xDzANBgNVBAMMBlBTRl9DQTEoMCYG +CSqGSIb3DQEJARYZaW5mcmFzdHJ1Y3R1cmVAcHl0aG9uLm9yZzAeFw0yNDAyMTIx +NzU0MDZaFw0yOTAyMTAxNzU0MDZaMIGwMQswCQYDVQQGEwJVUzEPMA0GA1UECAwG +T3JlZ29uMRIwEAYDVQQHDAlCZWF2ZXJ0b24xIzAhBgNVBAoMGlB5dGhvbiBTb2Z0 +d2FyZSBGb3VuZGF0aW9uMRwwGgYDVQQLDBNJbmZyYXN0cnVjdHVyZSBUZWFtMQ8w +DQYDVQQDDAZQU0ZfQ0ExKDAmBgkqhkiG9w0BCQEWGWluZnJhc3RydWN0dXJlQHB5 +dGhvbi5vcmcwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCXAZagv2UK +AEnnnnrK/WWcZIKo/l+HTgL01XhReu9CDNs3f3ESlRT3Y4Hbla/pYRu9VM8tMGYS +xG5FGJQ2JPVnKCb3mIEC7wy9+VOaQIp3l8+o0lDQhsOZs78ZA8XQpNLD5OURsUHJ +re1U6WOTryMJwxpO+DzSBU+oSwfdn2k0BAJqSeIU45hHXeHO24z7GePuk3I1wb+E +vfhtdIF/tHvF1I6h7ntmHUeUWYrTKXKB9meMAFwEC1ZNoN1z05X68cSeK8dAsxYh +ghmQnUZ4hHH8pLlhYW/QBTol0nutwgHPyC9FIJnZzX50xAMRx3TKP1IbIehWBwF2 +CYJq6pRBZ1mfAgMBAAGjUzBRMB0GA1UdDgQWBBQrAQVRNWd6eVr6ZGn8vshzgS09 +qDAfBgNVHSMEGDAWgBQrAQVRNWd6eVr6ZGn8vshzgS09qDAPBgNVHRMBAf8EBTAD +AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBmtyljZ1q2manMvIMEtXtc9lq3gwxIP4Pq +ic5hKuEHDSy5iN0vZRhoqfgPzXMy61zCrvLmvxv8nN2B4Us44KQRzWwDvi8SavfQ +LxRZ4KLe5Bg7MNfIKM/ZqYqHIt1FtVFYR7UyEILN/yDCyQC+8n6s8RLmT5OtZHPL +0YAyHgdao4qCICkZShbCukq81ULvkq7i6QvHWZrVGAIc/1nN71QNEUMr9KtlTKO3 +TeSd+l13+CDGwMXUpglDiFL329TmG5pKr/zoTCGDmRvEfRPtICwY3FgqGDpmIwhw +dXq0JPGHrFODeVrchUMSGqXhAZ+k/9YdJlGLbv3WJmD1GwFTs3Wf +-----END CERTIFICATE----- \ No newline at end of file diff --git a/infra/cdn/main.tf b/infra/cdn/main.tf new file mode 100644 index 000000000..12d1fbba4 --- /dev/null +++ b/infra/cdn/main.tf @@ -0,0 +1,345 @@ +resource "fastly_service_vcl" "python_org" { + name = var.name + default_ttl = var.default_ttl + http3 = false + stale_if_error = false + stale_if_error_ttl = 43200 + activate = false + + domain { + name = var.domain + } + + dynamic "domain" { + for_each = var.extra_domains + content { + name = domain.value + } + } + + backend { + name = "cabotage" + address = var.backend_address + port = 443 + shield = "iad-va-us" + auto_loadbalance = false + use_ssl = true + ssl_check_cert = true + ssl_cert_hostname = var.backend_address + ssl_sni_hostname = var.backend_address + weight = 100 + max_conn = 200 + connect_timeout = 1000 + first_byte_timeout = 30000 + between_bytes_timeout = 10000 + override_host = var.subdomain == "www.test.python.org" ? "www.python.org" : null + } + + backend { + name = "loadbalancer" + address = "lb.nyc1.psf.io" + port = 20004 + shield = "lga-ny-us" + healthcheck = "HAProxy Status" + auto_loadbalance = false + use_ssl = true + ssl_check_cert = true + ssl_cert_hostname = "lb.psf.io" + ssl_sni_hostname = "lb.psf.io" + ssl_ca_cert = file("${path.module}/certs/psf.io.pem") + weight = 100 + max_conn = 200 + connect_timeout = 1000 + first_byte_timeout = 15000 + between_bytes_timeout = 10000 + override_host = var.subdomain == "www.test.python.org" ? "www.python.org" : null + } + + acl { + name = "Generated_by_IP_block_list" + force_destroy = false + } + + cache_setting { + action = "pass" + cache_condition = "Force Pass No-Cache No-Store" + name = "Pass No-Cache No-Store" + stale_ttl = 0 + ttl = 0 + } + + condition { + name = "Force Pass No-Cache No-Store" + priority = 10 + statement = "beresp.http.Cache-Control ~ \"(no-cache|no-store)\"" + type = "CACHE" + } + condition { + name = "Generated by IP block list" + priority = 0 + statement = "client.ip ~ Generated_by_IP_block_list" + type = "REQUEST" + } + condition { + name = "HSTS w/ subdomains" + priority = 10 + statement = "req.http.host == \"${var.subdomain}\"" + type = "RESPONSE" + } + condition { + name = "HSTS w/o subdomain" + priority = 10 + statement = "req.http.host == \"${var.domain}\"" + type = "RESPONSE" + } + condition { + name = "Homepage" + priority = 10 + statement = "req.url.path ~ \"^/$\"" + type = "REQUEST" + } + condition { + name = "Is Download" + priority = 10 + statement = "req.url ~ \"^/ftp/\"" + type = "REQUEST" + } + condition { + name = "Is Not Download" + priority = 5 + statement = "req.url !~ \"^/ftp/\"" + type = "REQUEST" + } + condition { + name = "Uncacheable URLs" + priority = 10 + statement = "req.url ~ \"^/(api|admin)/\"" + type = "REQUEST" + } + condition { + name = "apex redirect" + priority = 10 + statement = "req.http.Host == \"python.org\"" + type = "RESPONSE" + } + condition { + name = "apex" + priority = 1 + statement = "req.http.host == \"python.org\"" + type = "REQUEST" + } + + gzip { + name = "Default rules" + content_types = [ + "application/javascript", + "text/css", + "application/javascript", + "text/javascript", + "application/json", + "application/vnd.ms-fontobject", + "application/x-font-opentype", + "application/x-font-truetype", + "application/x-font-ttf", + "application/xml", + "font/eot", + "font/opentype", + "font/otf", + "image/svg+xml", + "image/vnd.microsoft.icon", + "text/plain", + "text/xml", + ] + } + + header { + action = "delete" + destination = "http.Cookie" + name = "Remove cookies" + priority = 10 + request_condition = "Is Download" + type = "request" + } + header { + action = "set" + destination = "backend" + name = "Is Download Director" + priority = 10 + request_condition = "Is Download" + source = "F_loadbalancer" + type = "request" + } + header { + action = "set" + destination = "backend" + name = "Is Not Download Backend" + priority = 10 + request_condition = "Is Not Download" + source = "F_cabotage" + type = "request" + } + header { + action = "set" + destination = "http.Fastly-Token" + name = "Fastly Token" + priority = 10 + source = "\"${var.fastly_header_token}\"" + type = "request" + } + header { + action = "set" + destination = "http.Location" + name = "www redirect" + priority = 10 + response_condition = "apex redirect" + source = "\"https://${var.subdomain}\" + req.url" + type = "response" + } + header { + action = "set" + destination = "http.Strict-Transport-Security" + name = "HSTS w/ subdomains" + priority = 10 + response_condition = "HSTS w/ subdomains" + source = "\"max-age=63072000; includeSubDomains; preload\"" + type = "response" + } + header { + action = "set" + destination = "http.Strict-Transport-Security" + name = "HSTS w/o subdomains" + priority = 10 + response_condition = "HSTS w/o subdomain" + source = "\"max-age=315360000; preload\"" + type = "response" + } + header { + action = "set" + destination = "url" + name = "Chop off query string" + priority = 10 + request_condition = "Is Download" + source = "regsub(req.url, \"\\?.*$\", \"\")" + type = "request" + } + header { + action = "set" + destination = "url" + name = "Strip Query Strings" + priority = 10 + request_condition = "Homepage" + source = "req.url.path" + type = "request" + } + + healthcheck { + check_interval = 15000 + expected_response = 200 + host = var.domain + http_version = "1.1" + initial = 4 + method = "HEAD" + name = "HAProxy Status" + path = "/_haproxy_status" + threshold = 3 + timeout = 5000 + window = 5 + } + + logging_datadog { + name = "ratelimit-debug" + token = var.datadog_key + region = "US" + } + + logging_s3 { + name = "psf-fastly-logs" + bucket_name = "psf-fastly-logs-eu-west-1" + domain = "s3-eu-west-1.amazonaws.com" + path = "/${replace(var.subdomain, ".", "-")}/%Y/%m/%d/" + period = 3600 + gzip_level = 9 + format = "%h \"%%{now}V\" %l \"%%{req.request}V %%{req.url}V\" %%{req.proto}V %>s %%{resp.http.Content-Length}V %%{resp.http.age}V \"%%{resp.http.x-cache}V\" \"%%{resp.http.x-cache-hits}V\" \"%%{req.http.content-type}V\" \"%%{req.http.accept-language}V\" \"%%{cstr_escape(req.http.user-agent)}V\"" + timestamp_format = "%Y-%m-%dT%H:%M:%S.000" + redundancy = "standard" + format_version = 2 + message_type = "classic" + s3_access_key = var.s3_logging_keys["access_key"] + s3_secret_key = var.s3_logging_keys["secret_key"] + } + + logging_syslog { + name = "pythonorg" + address = "cdn-logs.nyc1.psf.io" + port = 514 + format = "%h \"%%{now}V\" %l \"%%{req.request}V %%{req.url}V\" %%{req.proto}V %>s %%{resp.http.Content-Length}V %%{resp.http.age}V \"%%{resp.http.x-cache}V\" \"%%{resp.http.x-cache-hits}V\" \"%%{req.http.content-type}V\" \"%%{req.http.accept-language}V\" \"%%{cstr_escape(req.http.user-agent)}V\"" + } + + product_enablement { + bot_management = true + brotli_compression = false + domain_inspector = true + image_optimizer = false + origin_inspector = true + websockets = false + } + + rate_limiter { + action = "log_only" + client_key = "client.ip" + feature_revision = 1 + http_methods = "GET,PUT,TRACE,POST,HEAD,DELETE,PATCH,OPTIONS" + logger_type = "datadog" + name = "${var.domain} backends" + penalty_box_duration = 2 + rps_limit = 10 + window_size = 10 + + response { + content = <<-EOT + + + Too Many Requests + + +

Too Many Requests

+ + + EOT + content_type = "text/html" + status = 429 + } + } + + request_setting { + action = null + bypass_busy_wait = false + force_ssl = true + max_stale_age = 86400 + name = "Default cache policy" + xff = "append" + } + request_setting { + action = "pass" + bypass_busy_wait = false + force_ssl = false + max_stale_age = 60 + name = "Force Pass" + request_condition = "Uncacheable URLs" + xff = "append" + } + + response_object { + name = "www redirect" + request_condition = "apex" + response = "Moved Permanently" + status = 301 + } + response_object { + content_type = "text/html" + name = "Generated by IP block list" + request_condition = "Generated by IP block list" + response = "Forbidden" + status = 403 + } +} diff --git a/infra/cdn/providers.tf b/infra/cdn/providers.tf new file mode 100644 index 000000000..201f5de4a --- /dev/null +++ b/infra/cdn/providers.tf @@ -0,0 +1,4 @@ +provider "fastly" { + alias = "cdn" + api_key = var.fastly_key +} diff --git a/infra/cdn/variables.tf b/infra/cdn/variables.tf new file mode 100644 index 000000000..4cbf6db6e --- /dev/null +++ b/infra/cdn/variables.tf @@ -0,0 +1,43 @@ +variable "fastly_key" { + type = string + description = "API key for the Fastly VCL edge configuration." +} +variable "fastly_header_token" { + description = "Fastly header token ensure we only allow Fastly to access the service" + type = string + sensitive = true +} +variable "datadog_key" { + type = string + description = "API key for Datadog logging" + sensitive = true +} +variable "s3_logging_keys" { + type = map(string) + description = "S3 bucket keys for Fastly logging" + sensitive = true +} +variable "name" { + type = string + description = "The name of the Fastly service." +} +variable "domain" { + type = string + description = "The domain name of the service." +} +variable "subdomain" { + type = string + description = "The subdomain of the service." +} +variable "extra_domains" { + type = list(string) + description = "Extra domains to add to the service." +} +variable "backend_address" { + type = string + description = "The hostname of the backend service." +} +variable "default_ttl" { + type = number + description = "The default TTL for the service." +} \ No newline at end of file diff --git a/infra/cdn/versions.tf b/infra/cdn/versions.tf new file mode 100644 index 000000000..da9c01f79 --- /dev/null +++ b/infra/cdn/versions.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + fastly = { + source = "fastly/fastly" + version = "5.13.0" + } + } +} diff --git a/infra/config.tf b/infra/config.tf new file mode 100644 index 000000000..65b1a5210 --- /dev/null +++ b/infra/config.tf @@ -0,0 +1,9 @@ +# Connect us to TF Cloud for remote deploys +terraform { + cloud { + organization = "psf" + workspaces { + name = "pythondotorg-infra" + } + } +} diff --git a/infra/main.tf b/infra/main.tf new file mode 100644 index 000000000..b3ec26a77 --- /dev/null +++ b/infra/main.tf @@ -0,0 +1,32 @@ +module "fastly_production" { + source = "./cdn" + + name = "www.python.org" + domain = "python.org" + subdomain = "www.python.org" + extra_domains = ["www.python.org"] + backend_address = "pythondotorg.ingress.us-east-2.psfhosted.computer" + default_ttl = 3600 + + datadog_key = var.DATADOG_API_KEY + fastly_key = var.FASTLY_API_KEY + fastly_header_token = var.FASTLY_HEADER_TOKEN + s3_logging_keys = var.fastly_s3_logging +} + +module "fastly_staging" { + source = "./cdn" + + name = "test.python.org" + domain = "test.python.org" + subdomain = "www.test.python.org" + extra_domains = ["www.test.python.org"] + # TODO: adjust to test-pythondotorg when done testing NGWAF + backend_address = "pythondotorg.ingress.us-east-2.psfhosted.computer" + default_ttl = 3600 + + datadog_key = var.DATADOG_API_KEY + fastly_key = var.FASTLY_API_KEY + fastly_header_token = var.FASTLY_HEADER_TOKEN + s3_logging_keys = var.fastly_s3_logging +} diff --git a/infra/variables.tf b/infra/variables.tf new file mode 100644 index 000000000..ec23b23ec --- /dev/null +++ b/infra/variables.tf @@ -0,0 +1,20 @@ +variable "FASTLY_API_KEY" { + type = string + description = "API key for the Fastly VCL edge configuration." + sensitive = true +} +variable "FASTLY_HEADER_TOKEN" { + description = "Fastly Token for authentication" + type = string + sensitive = true +} +variable "DATADOG_API_KEY" { + type = string + description = "API key for Datadog logging" + sensitive = true +} +variable "fastly_s3_logging" { + type = map(string) + description = "S3 bucket keys for Fastly logging" + sensitive = true +} \ No newline at end of file diff --git a/jobs/__init__.py b/jobs/__init__.py index 3716a978d..e69de29bb 100644 --- a/jobs/__init__.py +++ b/jobs/__init__.py @@ -1 +0,0 @@ -default_app_config = 'jobs.apps.JobsAppConfig' diff --git a/jobs/migrations/0021_alter_job_creator_alter_job_last_modified_by_and_more.py b/jobs/migrations/0021_alter_job_creator_alter_job_last_modified_by_and_more.py new file mode 100644 index 000000000..a82f65ac9 --- /dev/null +++ b/jobs/migrations/0021_alter_job_creator_alter_job_last_modified_by_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.11 on 2024-09-05 17:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('jobs', '0020_auto_20191101_1601'), + ] + + operations = [ + migrations.AlterField( + model_name='job', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='job', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='jobreviewcomment', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='jobreviewcomment', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/jobs/signals.py b/jobs/signals.py index 9c470d83a..0317ff716 100644 --- a/jobs/signals.py +++ b/jobs/signals.py @@ -1,10 +1,10 @@ from django.dispatch import Signal # Sent after job offer was submitted for review -job_was_submitted = Signal(providing_args=['job']) +job_was_submitted = Signal() # Sent after job offer was approved -job_was_approved = Signal(providing_args=['approving_user', 'job']) +job_was_approved = Signal() # Sent after job offer was rejected -job_was_rejected = Signal(providing_args=['rejecting_user', 'job']) +job_was_rejected = Signal() # Sent after comment was posted -comment_was_posted = Signal(providing_args=['comment']) +comment_was_posted = Signal() diff --git a/mailing/tests/__init__.py b/mailing/tests/__init__.py index d85108ab5..8d8b4f5c7 100644 --- a/mailing/tests/__init__.py +++ b/mailing/tests/__init__.py @@ -1 +1 @@ -# Create your tests here +"""Tests for the mailing app.""" diff --git a/mailing/tests/forms.py b/mailing/tests/forms.py new file mode 100644 index 000000000..b433adea6 --- /dev/null +++ b/mailing/tests/forms.py @@ -0,0 +1,13 @@ +"""Forms to be used in mailing tests.""" + +from mailing.forms import BaseEmailTemplateForm +from mailing.tests.models import MockEmailTemplate + + +class TestBaseEmailTemplateForm(BaseEmailTemplateForm): + """Base email template form for testing.""" + + class Meta: + """Metaclass for the form.""" + model = MockEmailTemplate + fields = "__all__" diff --git a/mailing/tests/models.py b/mailing/tests/models.py new file mode 100644 index 000000000..917e8dfb9 --- /dev/null +++ b/mailing/tests/models.py @@ -0,0 +1,12 @@ +"""Models to be used in mailing tests.""" + +from mailing.models import BaseEmailTemplate + + +class MockEmailTemplate(BaseEmailTemplate): + """Mock model for BaseEmailTemplate to use in tests.""" + + class Meta: + """Metaclass for MockEmailTemplate to avoid creating a table in the database.""" + app_label = 'mailing' + managed = False diff --git a/mailing/tests/test_forms.py b/mailing/tests/test_forms.py index 1e9165a0c..f7a0c6890 100644 --- a/mailing/tests/test_forms.py +++ b/mailing/tests/test_forms.py @@ -1,6 +1,7 @@ +"""Tests for mailing app forms.""" from django.test import TestCase -from mailing.forms import BaseEmailTemplateForm +from mailing.tests.forms import TestBaseEmailTemplateForm class BaseEmailTemplateFormTests(TestCase): @@ -14,16 +15,16 @@ def setUp(self): def test_validate_required_fields(self): required = set(self.data) - form = BaseEmailTemplateForm(data={}) + form = TestBaseEmailTemplateForm(data={}) self.assertFalse(form.is_valid()) self.assertEqual(required, set(form.errors)) def test_validate_with_correct_data(self): - form = BaseEmailTemplateForm(data=self.data) + form = TestBaseEmailTemplateForm(data=self.data) self.assertTrue(form.is_valid()) def test_invalid_form_if_broken_template_syntax(self): self.data["content"] = "Invalid syntax {% invalid %}" - form = BaseEmailTemplateForm(data=self.data) + form = TestBaseEmailTemplateForm(data=self.data) self.assertFalse(form.is_valid()) self.assertIn("content", form.errors, form.errors) diff --git a/membership/__init__.py b/membership/__init__.py index 2e00144a5..e69de29bb 100644 --- a/membership/__init__.py +++ b/membership/__init__.py @@ -1 +0,0 @@ -default_app_config = 'membership.apps.MembershipAppConfig' diff --git a/minutes/__init__.py b/minutes/__init__.py index 862de63c6..e69de29bb 100644 --- a/minutes/__init__.py +++ b/minutes/__init__.py @@ -1 +0,0 @@ -default_app_config = 'minutes.apps.MinutesAppConfig' diff --git a/minutes/migrations/0003_alter_minutes_creator_alter_minutes_last_modified_by.py b/minutes/migrations/0003_alter_minutes_creator_alter_minutes_last_modified_by.py new file mode 100644 index 000000000..07e512874 --- /dev/null +++ b/minutes/migrations/0003_alter_minutes_creator_alter_minutes_last_modified_by.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.11 on 2024-09-05 17:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('minutes', '0002_auto_20150416_1853'), + ] + + operations = [ + migrations.AlterField( + model_name='minutes', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='minutes', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/minutes/tests/test_models.py b/minutes/tests/test_models.py index fcd9bf571..4b5603641 100644 --- a/minutes/tests/test_models.py +++ b/minutes/tests/test_models.py @@ -21,16 +21,10 @@ def setUp(self): ) def test_draft(self): - self.assertQuerysetEqual( - Minutes.objects.draft(), - [''] - ) + self.assertQuerySetEqual(Minutes.objects.draft(), [''], transform=repr) def test_published(self): - self.assertQuerysetEqual( - Minutes.objects.published(), - [''] - ) + self.assertQuerySetEqual(Minutes.objects.published(), [''], transform=repr) def test_date_methods(self): self.assertEqual(self.m1.get_date_year(), '2012') diff --git a/nominations/__init__.py b/nominations/__init__.py index 368a604a5..e69de29bb 100644 --- a/nominations/__init__.py +++ b/nominations/__init__.py @@ -1 +0,0 @@ -default_app_config = 'nominations.apps.NominationsAppConfig' diff --git a/pages/__init__.py b/pages/__init__.py index 6f1e657eb..e69de29bb 100644 --- a/pages/__init__.py +++ b/pages/__init__.py @@ -1 +0,0 @@ -default_app_config = 'pages.apps.PagesAppConfig' diff --git a/pages/migrations/0004_alter_page_creator_alter_page_last_modified_by.py b/pages/migrations/0004_alter_page_creator_alter_page_last_modified_by.py new file mode 100644 index 000000000..19c5a6082 --- /dev/null +++ b/pages/migrations/0004_alter_page_creator_alter_page_last_modified_by.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.11 on 2024-09-05 17:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('pages', '0003_auto_20230214_2113'), + ] + + operations = [ + migrations.AlterField( + model_name='page', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='page', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/pages/tests/test_api.py b/pages/tests/test_api.py index 4026bac75..1c4cc6184 100644 --- a/pages/tests/test_api.py +++ b/pages/tests/test_api.py @@ -30,7 +30,7 @@ def test_get_published_pages(self): def test_get_all_pages(self): # Login to get all pages. url = self.create_url('page') - response = self.client.get(url, HTTP_AUTHORIZATION=self.Authorization) + response = self.client.get(url, headers={"authorization": self.Authorization}) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 3) @@ -41,7 +41,7 @@ def test_filter_page(self): self.assertEqual(len(response.data), 1) # Login to filter all pages. - response = self.client.get(url, HTTP_AUTHORIZATION=self.Authorization) + response = self.client.get(url, headers={"authorization": self.Authorization}) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 2) @@ -53,7 +53,7 @@ def test_filter_page(self): self.assertEqual(len(response.data), 0) # This should return only unpublished pages. - response = self.client.get(url, HTTP_AUTHORIZATION=self.Authorization) + response = self.client.get(url, headers={"authorization": self.Authorization}) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 1) diff --git a/pages/tests/test_models.py b/pages/tests/test_models.py index 2215cc812..eac62f102 100644 --- a/pages/tests/test_models.py +++ b/pages/tests/test_models.py @@ -8,11 +8,11 @@ class PageModelTests(BasePageTests): - def test_published(self): - self.assertQuerysetEqual(Page.objects.published(), ['']) - def test_draft(self): - self.assertQuerysetEqual(Page.objects.draft(), ['']) + self.assertQuerySetEqual(Page.objects.draft(), [''], transform=repr) + + def test_published(self): + self.assertQuerySetEqual(Page.objects.published(), [''], transform=repr) def test_get_title(self): one = Page.objects.get(path='one') diff --git a/peps/__init__.py b/peps/__init__.py index c71df1925..e69de29bb 100644 --- a/peps/__init__.py +++ b/peps/__init__.py @@ -1 +0,0 @@ -default_app_config = 'peps.apps.PepsAppConfig' diff --git a/prod-requirements.txt b/prod-requirements.txt index c2ee89acf..a7c4022f1 100644 --- a/prod-requirements.txt +++ b/prod-requirements.txt @@ -1,8 +1,8 @@ -gunicorn==19.9.0 +gunicorn==22.0.0 raven==6.10.0 # Heroku -Whitenoise==6.0.0 # 6.0.0 is latest version that supports Django 2.2 -django-storages==1.12.3 # 1.12.3 is latest version that supports Django 2.2 +Whitenoise==6.6.0 # 6.4.0 is first version that supports Django 4.2 +django-storages==1.42.2 # 1.42.2 is first version that supports Django 4.2 boto3==1.26.165 diff --git a/pydotorg/settings/base.py b/pydotorg/settings/base.py index a602dff7e..9697a6ea8 100644 --- a/pydotorg/settings/base.py +++ b/pydotorg/settings/base.py @@ -31,6 +31,12 @@ ) } +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +"""The default primary key field type for Django models. + +Required during the Django 2.2 -> 4.2 migration. +""" + # celery settings _REDIS_URL = config("REDIS_URL", default="redis://redis:6379/0") @@ -53,7 +59,6 @@ TIME_ZONE = 'UTC' LANGUAGE_CODE = 'en-us' USE_I18N = True -USE_L10N = True USE_TZ = True DATE_FORMAT = 'Y-m-d' @@ -74,7 +79,14 @@ STATICFILES_DIRS = [ os.path.join(BASE, 'static'), ] -STATICFILES_STORAGE = 'pipeline.storage.PipelineStorage' +STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": 'pipeline.storage.PipelineStorage', + }, +} STATICFILES_FINDERS = ( 'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder', @@ -98,6 +110,8 @@ ACCOUNT_UNIQUE_EMAIL = True ACCOUNT_EMAIL_VERIFICATION = 'mandatory' ACCOUNT_AUTHENTICATION_METHOD = 'username_email' +# TODO: Enable enumeration prevention +ACCOUNT_PREVENT_ENUMERATION = False SOCIALACCOUNT_EMAIL_REQUIRED = True SOCIALACCOUNT_EMAIL_VERIFICATION = True SOCIALACCOUNT_QUERY_EMAIL = True @@ -157,6 +171,7 @@ 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'pages.middleware.PageFallbackMiddleware', 'django.contrib.redirects.middleware.RedirectFallbackMiddleware', + 'allauth.account.middleware.AccountMiddleware', ] AUTH_USER_MODEL = 'users.User' diff --git a/pydotorg/settings/cabotage.py b/pydotorg/settings/cabotage.py index d73beb83c..4661fbf66 100644 --- a/pydotorg/settings/cabotage.py +++ b/pydotorg/settings/cabotage.py @@ -44,8 +44,14 @@ ] + MIDDLEWARE MEDIAFILES_LOCATION = 'media' -DEFAULT_FILE_STORAGE = 'custom_storages.storages.MediaStorage' -STATICFILES_STORAGE = 'custom_storages.storages.PipelineManifestStorage' +STORAGES = { + "default": { + "BACKEND": 'custom_storages.storages.MediaStorage', + }, + "staticfiles": { + "BACKEND": 'custom_storages.storages.PipelineManifestStorage', + }, +} EMAIL_HOST = config('EMAIL_HOST') EMAIL_HOST_USER = config('EMAIL_HOST_USER') diff --git a/pydotorg/settings/static.py b/pydotorg/settings/static.py index 3d93e113e..5dcbf6f92 100644 --- a/pydotorg/settings/static.py +++ b/pydotorg/settings/static.py @@ -21,5 +21,11 @@ ] + MIDDLEWARE MEDIAFILES_LOCATION = 'media' -DEFAULT_FILE_STORAGE = 'custom_storages.storages.MediaStorage' -STATICFILES_STORAGE = 'custom_storages.storages.PipelineManifestStorage' +STORAGES = { + "default": { + "BACKEND": 'custom_storages.storages.MediaStorage', + }, + "staticfiles": { + "BACKEND": 'custom_storages.storages.PipelineManifestStorage', + }, +} diff --git a/pydotorg/urls.py b/pydotorg/urls.py index f6ee8001d..f87ab496b 100644 --- a/pydotorg/urls.py +++ b/pydotorg/urls.py @@ -1,7 +1,8 @@ -from django.conf.urls import handler404, include +from django.conf.urls import handler404 from django.contrib import admin from django.contrib.staticfiles.urls import staticfiles_urlpatterns from django.conf.urls.static import static +from django.urls import include from django.urls import path, re_path from django.views.generic.base import TemplateView from django.conf import settings diff --git a/pydotorg/urls_api.py b/pydotorg/urls_api.py index 0c27699b1..4afc7122e 100644 --- a/pydotorg/urls_api.py +++ b/pydotorg/urls_api.py @@ -1,4 +1,4 @@ -from django.conf.urls import url +from django.urls import re_path from rest_framework import routers from tastypie.api import Api @@ -22,6 +22,6 @@ router.register(r'downloads/release_file', ReleaseFileViewSet) urlpatterns = [ - url(r'sponsors/logo-placement/', LogoPlacementeAPIList.as_view(), name="logo_placement_list"), - url(r'sponsors/sponsorship-assets/', SponsorshipAssetsAPIList.as_view(), name="assets_list"), + re_path(r'sponsors/logo-placement/', LogoPlacementeAPIList.as_view(), name="logo_placement_list"), + re_path(r'sponsors/sponsorship-assets/', SponsorshipAssetsAPIList.as_view(), name="assets_list"), ] diff --git a/sponsors/__init__.py b/sponsors/__init__.py index 016e79088..e69de29bb 100644 --- a/sponsors/__init__.py +++ b/sponsors/__init__.py @@ -1 +0,0 @@ -default_app_config = 'sponsors.apps.SponsorsAppConfig' diff --git a/sponsors/admin.py b/sponsors/admin.py index 88aff8c57..dc7278c08 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -38,18 +38,22 @@ class AssetsInline(GenericTabularInline): has_delete_permission = lambda self, request, obj: False readonly_fields = ["internal_name", "user_submitted_info", "value"] + @admin.display( + description="Submitted information" + ) def value(self, obj=None): if not obj or not obj.value: return "" return obj.value - value.short_description = "Submitted information" + @admin.display( + description="Fullfilled data?", + boolean=True, + ) def user_submitted_info(self, obj=None): return bool(self.value(obj)) - user_submitted_info.short_description = "Fullfilled data?" - user_submitted_info.boolean = True @admin.register(SponsorshipProgram) @@ -110,6 +114,7 @@ class ProvidedFileAssetConfigurationInline(StackedPolymorphicInline.Child): ProvidedFileAssetConfigurationInline, ] + @admin.register(SponsorshipBenefit) class SponsorshipBenefitAdmin(PolymorphicInlineSupportMixin, OrderedModelAdmin): change_form_template = "sponsors/admin/sponsorshipbenefit_change_form.html" @@ -179,12 +184,12 @@ def update_related_sponsorships(self, *args, **kwargs): @admin.register(SponsorshipPackage) class SponsorshipPackageAdmin(OrderedModelAdmin): ordering = ("-year", "order",) - list_display = ["name", "year", "advertise", "allow_a_la_carte", "move_up_down_links"] + list_display = ["name", "year", "advertise", "allow_a_la_carte", "get_benefit_split", "move_up_down_links"] list_filter = ["advertise", "year", "allow_a_la_carte"] search_fields = ["name"] def get_readonly_fields(self, request, obj=None): - readonly = [] + readonly = ["get_benefit_split"] if obj: readonly.append("slug") if not request.user.is_superuser: @@ -196,6 +201,30 @@ def get_prepopulated_fields(self, request, obj=None): return {'slug': ['name']} return {} + def get_benefit_split(self, obj: SponsorshipPackage) -> str: + colors = [ + "#ffde57", # Python Gold + "#4584b6", # Python Blue + "#646464", # Python Grey + ] + split = obj.get_default_revenue_split() + # rotate colors through our available palette + if len(split) > len(colors): + colors = colors * (1 + (len(split) // len(colors))) + # build some span elements to show the percentages and have the program name in the title (to show on hover) + widths, spans = [], [] + for i, (name, pct) in enumerate(split): + pct_str = f"{pct:.0f}%" + widths.append(pct_str) + spans.append(f"{pct_str}") + # define a style that will show our span elements like a single horizontal stacked bar chart + style = f'color:#fff;text-align:center;cursor:pointer;display:grid;grid-template-columns:{" ".join(widths)}' + # wrap it all up and put a bow on it + html = f"
{''.join(spans)}
" + return mark_safe(html) + + get_benefit_split.short_description = "Revenue split" + class SponsorContactInline(admin.TabularInline): model = SponsorContact @@ -210,10 +239,12 @@ class SponsorshipsInline(admin.TabularInline): can_delete = False extra = 0 + @admin.display( + description="ID" + ) def link(self, obj): url = reverse("admin:sponsors_sponsorship_change", args=[obj.id]) return mark_safe(f"{obj.id}") - link.short_description = "ID" @admin.register(Sponsor) @@ -464,10 +495,12 @@ def get_queryset(self, *args, **kwargs): qs = super().get_queryset(*args, **kwargs) return qs.select_related("sponsor", "package", "submited_by") + @admin.action( + description='Send notifications to selected' + ) def send_notifications(self, request, queryset): return views_admin.send_sponsorship_notifications_action(self, request, queryset) - send_notifications.short_description = 'Send notifications to selected' def get_readonly_fields(self, request, obj): readonly_fields = [ @@ -503,11 +536,16 @@ def get_readonly_fields(self, request, obj): return readonly_fields + @admin.display( + description="Sponsor" + ) def sponsor_link(self, obj): url = reverse("admin:sponsors_sponsor_change", args=[obj.sponsor.id]) return mark_safe(f"{obj.sponsor.name}") - sponsor_link.short_description = "Sponsor" + @admin.display( + description="Estimated cost" + ) def get_estimated_cost(self, obj): cost = None html = "This sponsorship has not customizations so there's no estimated cost" @@ -517,8 +555,10 @@ def get_estimated_cost(self, obj): html = f"{cost} USD
Important: {msg}" return mark_safe(html) - get_estimated_cost.short_description = "Estimated cost" + @admin.display( + description="Contract" + ) def get_contract(self, obj): if not obj.contract: return "---" @@ -526,7 +566,6 @@ def get_contract(self, obj): html = f"{obj.contract}" return mark_safe(html) - get_contract.short_description = "Contract" def get_urls(self): urls = super().get_urls() @@ -572,21 +611,30 @@ def get_urls(self): ] return my_urls + urls + @admin.display( + description="Name" + ) def get_sponsor_name(self, obj): return obj.sponsor.name - get_sponsor_name.short_description = "Name" + @admin.display( + description="Description" + ) def get_sponsor_description(self, obj): return obj.sponsor.description - get_sponsor_description.short_description = "Description" + @admin.display( + description="Landing Page URL" + ) def get_sponsor_landing_page_url(self, obj): return obj.sponsor.landing_page_url - get_sponsor_landing_page_url.short_description = "Landing Page URL" + @admin.display( + description="Web Logo" + ) def get_sponsor_web_logo(self, obj): html = "{% load thumbnail %}{% thumbnail sponsor.web_logo '150x150' format='PNG' quality=100 as im %}{% endthumbnail %}" template = Template(html) @@ -594,8 +642,10 @@ def get_sponsor_web_logo(self, obj): html = template.render(context) return mark_safe(html) - get_sponsor_web_logo.short_description = "Web Logo" + @admin.display( + description="Print Logo" + ) def get_sponsor_print_logo(self, obj): img = obj.sponsor.print_logo html = "" @@ -606,13 +656,17 @@ def get_sponsor_print_logo(self, obj): html = template.render(context) return mark_safe(html) if html else "---" - get_sponsor_print_logo.short_description = "Print Logo" + @admin.display( + description="Primary Phone" + ) def get_sponsor_primary_phone(self, obj): return obj.sponsor.primary_phone - get_sponsor_primary_phone.short_description = "Primary Phone" + @admin.display( + description="Mailing/Billing Address" + ) def get_sponsor_mailing_address(self, obj): sponsor = obj.sponsor city_row = ( @@ -630,8 +684,10 @@ def get_sponsor_mailing_address(self, obj): html += f"

{sponsor.postal_code}

" return mark_safe(html) - get_sponsor_mailing_address.short_description = "Mailing/Billing Address" + @admin.display( + description="Contacts" + ) def get_sponsor_contacts(self, obj): html = "" contacts = obj.sponsor.contacts.all() @@ -651,8 +707,10 @@ def get_sponsor_contacts(self, obj): html += "" return mark_safe(html) - get_sponsor_contacts.short_description = "Contacts" + @admin.display( + description="Added by User" + ) def get_custom_benefits_added_by_user(self, obj): benefits = obj.user_customizations["added_by_user"] if not benefits: @@ -663,8 +721,10 @@ def get_custom_benefits_added_by_user(self, obj): ) return mark_safe(html) - get_custom_benefits_added_by_user.short_description = "Added by User" + @admin.display( + description="Removed by User" + ) def get_custom_benefits_removed_by_user(self, obj): benefits = obj.user_customizations["removed_by_user"] if not benefits: @@ -675,7 +735,6 @@ def get_custom_benefits_removed_by_user(self, obj): ) return mark_safe(html) - get_custom_benefits_removed_by_user.short_description = "Removed by User" def rollback_to_editing_view(self, request, pk): return views_admin.rollback_to_editing_view(self, request, pk) @@ -722,6 +781,9 @@ def get_urls(self): ] return my_urls + urls + @admin.display( + description="Links" + ) def links(self, obj): clone_form = CloneApplicationConfigForm() configured_years = clone_form.configured_years @@ -743,8 +805,10 @@ def links(self, obj): html += f"
  • {preview_label}" html += "" return mark_safe(html) - links.short_description = "Links" + @admin.display( + description="Other configured years" + ) def other_years(self, obj): clone_form = CloneApplicationConfigForm() configured_years = clone_form.configured_years @@ -775,7 +839,6 @@ def other_years(self, obj): html += "
  • " html += "" return mark_safe(html) - other_years.short_description = "Other configured years" def clone_application_config(self, request): return views_admin.clone_application_config(self, request) @@ -803,10 +866,12 @@ def get_queryset(self, *args, **kwargs): qs = super().get_queryset(*args, **kwargs) return qs.select_related("sponsorship__sponsor") + @admin.display( + description="Revision" + ) def get_revision(self, obj): return obj.revision if obj.is_draft else "Final" - get_revision.short_description = "Revision" fieldsets = [ ( @@ -874,6 +939,9 @@ def get_readonly_fields(self, request, obj): return readonly_fields + @admin.display( + description="Contract document" + ) def document_link(self, obj): html, url, msg = "---", "", "" @@ -891,8 +959,10 @@ def document_link(self, obj): html = f'{msg}' return mark_safe(html) - document_link.short_description = "Contract document" + @admin.display( + description="Sponsorship" + ) def get_sponsorship_url(self, obj): if not obj.sponsorship: return "---" @@ -900,7 +970,6 @@ def get_sponsorship_url(self, obj): html = f"{obj.sponsorship}" return mark_safe(html) - get_sponsorship_url.short_description = "Sponsorship" def get_urls(self): urls = super().get_urls() @@ -983,7 +1052,7 @@ def benefits_with_assets(self): return {str(b.id): b for b in benefits} def lookups(self, request, model_admin): - return [(k, b.name) for k, b in self.benefits_with_assets.items()] + return [(k, f"{b.name} ({b.year})") for k, b in self.benefits_with_assets.items()] def queryset(self, request, queryset): benefit = self.benefits_with_assets.get(self.value()) @@ -1065,14 +1134,19 @@ def all_sponsorships(self): qs = Sponsorship.objects.all().select_related("package", "sponsor") return {sp.id: sp for sp in qs} + @admin.display( + description="Value" + ) def get_value(self, obj): html = obj.value if obj.value and getattr(obj.value, "url", None): html = f"{obj.value}" return mark_safe(html) - get_value.short_description = "Value" + @admin.display( + description="Associated with" + ) def get_related_object(self, obj): """ Returns the content_object as an URL and performs better because @@ -1090,11 +1164,12 @@ def get_related_object(self, obj): html = f"{content_object}" return mark_safe(html) - get_related_object.short_description = "Associated with" + @admin.action( + description="Export selected" + ) def export_assets_as_zipfile(self, request, queryset): return views_admin.export_assets_as_zipfile(self, request, queryset) - export_assets_as_zipfile.short_description = "Export selected" class GenericAssetChildModelAdmin(PolymorphicChildModelAdmin): diff --git a/sponsors/contracts.py b/sponsors/contracts.py index 2720ec1c8..e0fd75b6c 100644 --- a/sponsors/contracts.py +++ b/sponsors/contracts.py @@ -4,6 +4,7 @@ from django.http import HttpResponse from django.template.loader import render_to_string from django.utils.dateformat import format +from unidecode import unidecode import pypandoc @@ -70,7 +71,7 @@ def render_contract_to_docx_response(request, contract, **context): ) response[ "Content-Disposition" - ] = f"attachment; filename={'sponsorship-renewal' if contract.sponsorship.renewal else 'sponsorship-contract'}-{contract.sponsorship.sponsor.name.replace(' ', '-').replace('.', '')}.docx" + ] = f"attachment; filename={'sponsorship-renewal' if contract.sponsorship.renewal else 'sponsorship-contract'}-{unidecode(contract.sponsorship.sponsor.name.replace(' ', '-').replace('.', ''))}.docx" return response diff --git a/sponsors/migrations/0102_auto_20240509_2037.py b/sponsors/migrations/0102_auto_20240509_2037.py new file mode 100644 index 000000000..2c68fa96b --- /dev/null +++ b/sponsors/migrations/0102_auto_20240509_2037.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2024-05-09 20:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0101_sponsor_linked_in_page_url'), + ] + + operations = [ + migrations.AlterField( + model_name='textasset', + name='text', + field=models.TextField(blank=True, default=''), + ), + ] diff --git a/sponsors/migrations/0103_alter_benefitfeature_polymorphic_ctype_and_more.py b/sponsors/migrations/0103_alter_benefitfeature_polymorphic_ctype_and_more.py new file mode 100644 index 000000000..e9eb9e3a2 --- /dev/null +++ b/sponsors/migrations/0103_alter_benefitfeature_polymorphic_ctype_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.11 on 2024-09-05 17:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('sponsors', '0102_auto_20240509_2037'), + ] + + operations = [ + migrations.AlterField( + model_name='benefitfeature', + name='polymorphic_ctype', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'), + ), + migrations.AlterField( + model_name='benefitfeatureconfiguration', + name='polymorphic_ctype', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'), + ), + migrations.AlterField( + model_name='genericasset', + name='polymorphic_ctype', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'), + ), + migrations.AlterField( + model_name='sponsor', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='sponsor', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='sponsorshipbenefit', + name='conflicts', + field=models.ManyToManyField(blank=True, help_text='For benefits that conflict with one another,', to='sponsors.sponsorshipbenefit', verbose_name='Conflicts'), + ), + ] diff --git a/sponsors/models/assets.py b/sponsors/models/assets.py index 4db7c9671..9b4899b5a 100644 --- a/sponsors/models/assets.py +++ b/sponsors/models/assets.py @@ -106,7 +106,7 @@ def value(self, value): class TextAsset(GenericAsset): - text = models.TextField(default="") + text = models.TextField(default="", blank=True) def __str__(self): return f"Text asset: {self.internal_name}" diff --git a/sponsors/models/benefits.py b/sponsors/models/benefits.py index 635b3f6b9..750f5af6c 100644 --- a/sponsors/models/benefits.py +++ b/sponsors/models/benefits.py @@ -146,7 +146,10 @@ def create_benefit_feature(self, sponsor_benefit, **kwargs): def get_clone_kwargs(self, new_benefit): kwargs = super().get_clone_kwargs(new_benefit) - kwargs["internal_name"] = f"{self.internal_name}_{new_benefit.year}" + if str(self.benefit.year) in self.internal_name: + kwargs["internal_name"] = self.internal_name.replace(str(self.benefit.year), str(new_benefit.year)) + else: + kwargs["internal_name"] = f"{self.internal_name}_{new_benefit.year}" due_date = kwargs.get("due_date") if due_date: kwargs["due_date"] = due_date.replace(year=new_benefit.year) diff --git a/sponsors/models/sponsorship.py b/sponsors/models/sponsorship.py index 7443d4d2c..d230e91c3 100644 --- a/sponsors/models/sponsorship.py +++ b/sponsors/models/sponsorship.py @@ -117,6 +117,18 @@ def clone(self, year: int): slug=self.slug, year=year, defaults=defaults ) + def get_default_revenue_split(self) -> list[tuple[str, float]]: + """ + Give the admin an indication of how revenue for sponsorships in this package will be divvied up + """ + values, key = {}, "program__name" + for benefit in self.benefits.values(key).annotate(amount=Sum("internal_value", default=0)).order_by("-amount"): + values[benefit[key]] = values.get(benefit[key], 0) + (benefit["amount"] or 0) + total = sum(values.values()) + if not total: + return [] # nothing to split! + return [(k, round(v / total * 100, 3)) for k, v in values.items()] + class SponsorshipProgram(OrderedModel): """ diff --git a/sponsors/tests/test_api.py b/sponsors/tests/test_api.py index caabd6aa1..3575e59e6 100644 --- a/sponsors/tests/test_api.py +++ b/sponsors/tests/test_api.py @@ -41,7 +41,7 @@ def tearDown(self): sponsor.print_logo.delete() def test_list_logo_placement_as_expected(self): - response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + response = self.client.get(self.url, headers={"authorization": self.authorization}) data = response.json() self.assertEqual(200, response.status_code) @@ -71,7 +71,7 @@ def test_list_logo_placement_as_expected(self): def test_invalid_token(self): Token.objects.all().delete() - response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + response = self.client.get(self.url, headers={"authorization": self.authorization}) self.assertEqual(401, response.status_code) def test_superuser_user_have_permission_by_default(self): @@ -79,19 +79,19 @@ def test_superuser_user_have_permission_by_default(self): self.user.is_superuser = True self.user.is_staff = True self.user.save() - response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + response = self.client.get(self.url, headers={"authorization": self.authorization}) self.assertEqual(200, response.status_code) def test_staff_have_permission_by_default(self): self.user.user_permissions.remove(self.permission) self.user.is_staff = True self.user.save() - response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + response = self.client.get(self.url, headers={"authorization": self.authorization}) self.assertEqual(200, response.status_code) def test_user_must_have_required_permission(self): self.user.user_permissions.remove(self.permission) - response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + response = self.client.get(self.url, headers={"authorization": self.authorization}) self.assertEqual(403, response.status_code) def test_filter_sponsorship_by_publisher(self): @@ -99,7 +99,7 @@ def test_filter_sponsorship_by_publisher(self): "publisher": PublisherChoices.PYPI.value, }) url = f"{self.url}?{querystring}" - response = self.client.get(url, HTTP_AUTHORIZATION=self.authorization) + response = self.client.get(url, headers={"authorization": self.authorization}) data = response.json() self.assertEqual(200, response.status_code) @@ -111,7 +111,7 @@ def test_filter_sponsorship_by_flight(self): "flight": LogoPlacementChoices.SIDEBAR.value, }) url = f"{self.url}?{querystring}" - response = self.client.get(url, HTTP_AUTHORIZATION=self.authorization) + response = self.client.get(url, headers={"authorization": self.authorization}) data = response.json() self.assertEqual(200, response.status_code) @@ -125,7 +125,7 @@ def test_bad_request_for_invalid_filters(self): "publisher": "invalid-publisher" }) url = f"{self.url}?{querystring}" - response = self.client.get(url, HTTP_AUTHORIZATION=self.authorization) + response = self.client.get(url, headers={"authorization": self.authorization}) data = response.json() self.assertEqual(400, response.status_code) @@ -162,7 +162,7 @@ def tearDown(self): def test_invalid_token(self): Token.objects.all().delete() - response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + response = self.client.get(self.url, headers={"authorization": self.authorization}) self.assertEqual(401, response.status_code) def test_superuser_user_have_permission_by_default(self): @@ -170,30 +170,30 @@ def test_superuser_user_have_permission_by_default(self): self.user.is_superuser = True self.user.is_staff = True self.user.save() - response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + response = self.client.get(self.url, headers={"authorization": self.authorization}) self.assertEqual(200, response.status_code) def test_staff_have_permission_by_default(self): self.user.user_permissions.remove(self.permission) self.user.is_staff = True self.user.save() - response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + response = self.client.get(self.url, headers={"authorization": self.authorization}) self.assertEqual(200, response.status_code) def test_user_must_have_required_permission(self): self.user.user_permissions.remove(self.permission) - response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + response = self.client.get(self.url, headers={"authorization": self.authorization}) self.assertEqual(403, response.status_code) def test_bad_request_if_no_internal_name(self): url = reverse_lazy("assets_list") - response = self.client.get(url, HTTP_AUTHORIZATION=self.authorization) + response = self.client.get(url, headers={"authorization": self.authorization}) self.assertEqual(400, response.status_code) self.assertIn("internal_name", response.json()) def test_list_assets_by_internal_name(self): # by default exclude assets with no value - response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + response = self.client.get(self.url, headers={"authorization": self.authorization}) data = response.json() self.assertEqual(200, response.status_code) self.assertEqual(0, len(data)) @@ -202,7 +202,7 @@ def test_list_assets_by_internal_name(self): self.txt_asset.value = "Text Content" self.txt_asset.save() - response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + response = self.client.get(self.url, headers={"authorization": self.authorization}) data = response.json() self.assertEqual(1, len(data)) @@ -216,7 +216,7 @@ def test_list_assets_by_internal_name(self): def test_enable_to_filter_by_assets_with_no_value_via_querystring(self): self.url += "&list_empty=true" - response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + response = self.client.get(self.url, headers={"authorization": self.authorization}) data = response.json() self.assertEqual(1, len(data)) @@ -230,7 +230,7 @@ def test_serialize_img_value_as_url_to_image(self): self.img_asset.save() url = reverse_lazy("assets_list") + f"?internal_name={self.img_asset.internal_name}" - response = self.client.get(url, HTTP_AUTHORIZATION=self.authorization) + response = self.client.get(url, headers={"authorization": self.authorization}) data = response.json() self.assertEqual(1, len(data)) diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 781e85c09..3566f0b08 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -1,4 +1,5 @@ from datetime import date, timedelta +import random from django.core.cache import cache from django.db import IntegrityError @@ -433,6 +434,22 @@ def test_clone_does_not_repeate_already_cloned_package(self): self.assertFalse(created) self.assertEqual(pkg_2023.pk, repeated_pkg_2023.pk) + def test_get_default_revenue_split(self): + benefits = baker.make(SponsorshipBenefit, internal_value=int(random.random() * 1000), _quantity=12) + program_names = set((b.program.name for b in benefits)) + pkg1 = baker.make(SponsorshipPackage, year=2024, advertise=True, logo_dimension=300, benefits=benefits[:3]) + pkg2 = baker.make(SponsorshipPackage, year=2024, advertise=True, logo_dimension=300, benefits=benefits[3:7]) + pkg3 = baker.make(SponsorshipPackage, year=2024, advertise=True, logo_dimension=300, benefits=benefits[7:]) + splits = [pkg.get_default_revenue_split() for pkg in (pkg1, pkg2, pkg3)] + split_names = set((name for split in splits for name, _ in split)) + totals = [sum((pct for _, pct in split)) for split in splits] + # since the split percentages are rounded, they may not always total exactly 100.000 + self.assertAlmostEqual(totals[0], 100, delta=0.1) + self.assertAlmostEqual(totals[1], 100, delta=0.1) + self.assertAlmostEqual(totals[2], 100, delta=0.1) + self.assertEqual(split_names, program_names) + + class SponsorContactModelTests(TestCase): def test_get_primary_contact_for_sponsor(self): sponsor = baker.make(Sponsor) diff --git a/successstories/__init__.py b/successstories/__init__.py index e2c2c1446..e69de29bb 100644 --- a/successstories/__init__.py +++ b/successstories/__init__.py @@ -1 +0,0 @@ -default_app_config = 'successstories.apps.SuccessstoriesAppConfig' diff --git a/successstories/admin.py b/successstories/admin.py index fdde8878c..bc15d2d11 100644 --- a/successstories/admin.py +++ b/successstories/admin.py @@ -24,6 +24,8 @@ def get_list_display(self, request): fields = list(super().get_list_display(request)) return fields + ['show_link', 'is_published', 'featured'] + @admin.display( + description='View on site' + ) def show_link(self, obj): return format_html(f'\U0001F517') - show_link.short_description = 'View on site' diff --git a/successstories/migrations/0012_alter_story_creator_alter_story_last_modified_by.py b/successstories/migrations/0012_alter_story_creator_alter_story_last_modified_by.py new file mode 100644 index 000000000..dee246421 --- /dev/null +++ b/successstories/migrations/0012_alter_story_creator_alter_story_last_modified_by.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.11 on 2024-09-05 17:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('successstories', '0011_auto_20220127_1923'), + ] + + operations = [ + migrations.AlterField( + model_name='story', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='story', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/successstories/tests/test_models.py b/successstories/tests/test_models.py index de5c0d577..418d27062 100644 --- a/successstories/tests/test_models.py +++ b/successstories/tests/test_models.py @@ -15,12 +15,13 @@ def test_published(self): self.assertEqual(len(Story.objects.published()), 2) def test_draft(self): - self.assertQuerysetEqual(Story.objects.draft(), - [f'']) + draft_stories = Story.objects.draft() + self.assertTrue(all(story.name == 'Fraft Story' for story in draft_stories)) def test_featured(self): - self.assertQuerysetEqual(Story.objects.featured(), - [f'']) + featured_stories = Story.objects.featured() + expected_repr = [f''] + self.assertQuerysetEqual(featured_stories, expected_repr, transform=repr) def test_get_admin_url(self): self.assertEqual(self.story1.get_admin_url(), diff --git a/templates/base.html b/templates/base.html index 424df06f6..b9f3df9c6 100644 --- a/templates/base.html +++ b/templates/base.html @@ -13,6 +13,8 @@ gtag('js', new Date()); gtag('config', 'G-TF35YF9CVH'); + + diff --git a/templates/python/documentation.html b/templates/python/documentation.html index c5779323c..7db3662d2 100644 --- a/templates/python/documentation.html +++ b/templates/python/documentation.html @@ -81,7 +81,7 @@

    P
    • FAQ: Sunsetting Python 2
    • Final Python 2.7 Release Schedule
    • -
    • Python 3 Statement
    • +
    • Python 3 Statement
    • Porting Python 2 Code to Python 3
        diff --git a/users/__init__.py b/users/__init__.py index 1bf67ae9c..e69de29bb 100644 --- a/users/__init__.py +++ b/users/__init__.py @@ -1 +0,0 @@ -default_app_config = 'users.apps.UsersAppConfig' diff --git a/users/admin.py b/users/admin.py index 1c003655c..36d7e30f3 100644 --- a/users/admin.py +++ b/users/admin.py @@ -26,7 +26,7 @@ class ApiKeyInline(TastypieApiKeyInline): @admin.register(User) class UserAdmin(BaseUserAdmin): - inlines = BaseUserAdmin.inlines + [ApiKeyInline, MembershipInline] + inlines = BaseUserAdmin.inlines + (ApiKeyInline, MembershipInline,) fieldsets = ( (None, {'fields': ('username', 'password')}), (_('Personal info'), {'fields': ( @@ -44,9 +44,11 @@ class UserAdmin(BaseUserAdmin): def has_add_permission(self, request): return False + @admin.display( + description='Name' + ) def full_name(self, obj): return obj.get_full_name() - full_name.short_description = 'Name' @admin.register(Membership) diff --git a/users/migrations/0015_alter_user_first_name.py b/users/migrations/0015_alter_user_first_name.py new file mode 100644 index 000000000..ac7715204 --- /dev/null +++ b/users/migrations/0015_alter_user_first_name.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-09-05 17:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0014_auto_20210801_2332'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='first_name', + field=models.CharField(blank=True, max_length=150, verbose_name='first name'), + ), + ] diff --git a/users/tests/test_forms.py b/users/tests/test_forms.py index 10ab95e32..897f41d6c 100644 --- a/users/tests/test_forms.py +++ b/users/tests/test_forms.py @@ -2,6 +2,7 @@ from django.test import TestCase from allauth.account.forms import SignupForm +from allauth.account.models import EmailAddress from users.forms import UserProfileForm, MembershipForm @@ -50,14 +51,16 @@ def test_duplicate_username(self): self.assertIn('username', form.errors) def test_duplicate_email(self): - User.objects.create_user('test1', 'test@example.com', 'testpass') + user = User.objects.create_user('test1', 'test@example.com', 'testpass') + EmailAddress.objects.create(user=user, email="test@example.com") - form = SignupForm({ + form = SignupForm(data={ 'username': 'username2', 'email': 'test@example.com', 'password1': 'password', - 'password2': 'password' + 'password2': 'password', }) + self.assertFalse(form.is_valid()) self.assertIn('email', form.errors) @@ -92,13 +95,8 @@ def test_non_ascii_username(self): 'password2': 'password', }) self.assertFalse(form.is_valid()) - self.assertEqual( - form.errors['username'], - [ - 'Enter a valid username. This value may contain only ' - 'English letters, numbers, and @/./+/-/_ characters.' - ] - ) + expected_error = 'Enter a valid username. This value may contain only unaccented lowercase a-z and uppercase A-Z letters, numbers, and @/./+/-/_ characters.' + self.assertIn(expected_error, form.errors['username']) def test_user_membership(self): form = MembershipForm({ diff --git a/users/tests/test_views.py b/users/tests/test_views.py index 13c226e5f..83b8330f9 100644 --- a/users/tests/test_views.py +++ b/users/tests/test_views.py @@ -2,7 +2,7 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.urls import reverse -from django.test import TestCase +from django.test import TestCase, override_settings from sponsors.forms import SponsorUpdateForm, SponsorRequiredAssetsForm from sponsors.models import Sponsorship, RequiredTextAssetConfiguration, SponsorBenefit @@ -11,8 +11,6 @@ from users.factories import UserFactory from users.models import Membership -from ..factories import MembershipFactory - User = get_user_model() @@ -245,7 +243,7 @@ def test_user_duplicate_username_email(self): response, 'A user with that username already exists.' ) self.assertContains( - response, 'A user is already registered with this e-mail address.' + response, 'A user is already registered with this email address.' ) def test_usernames(self): diff --git a/work_groups/__init__.py b/work_groups/__init__.py index ef2fbcce9..e69de29bb 100644 --- a/work_groups/__init__.py +++ b/work_groups/__init__.py @@ -1 +0,0 @@ -default_app_config = 'work_groups.apps.WorkGroupsAppConfig' diff --git a/work_groups/migrations/0005_alter_workgroup_creator_and_more.py b/work_groups/migrations/0005_alter_workgroup_creator_and_more.py new file mode 100644 index 000000000..a316aa482 --- /dev/null +++ b/work_groups/migrations/0005_alter_workgroup_creator_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.11 on 2024-09-05 17:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('work_groups', '0004_auto_20180705_0352'), + ] + + operations = [ + migrations.AlterField( + model_name='workgroup', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_creator', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='workgroup', + name='last_modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + ]