diff --git a/.python-version b/.python-version index c8cfe395..e4fba218 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.10 +3.12 diff --git a/config/settings/production.py b/config/settings/production.py index 84332a60..82c99e8b 100644 --- a/config/settings/production.py +++ b/config/settings/production.py @@ -80,7 +80,10 @@ # STATIC # ------------------------ -STATICFILES_STORAGE = "config.settings.production.StaticRootS3Boto3Storage" +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "config.settings.production.StaticRootS3Boto3Storage"}, +} STATIC_URL = f"https://{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/static/" # MEDIA diff --git a/documentcloud/addons/migrations/0031_addonevent_dismissed.py b/documentcloud/addons/migrations/0031_addonevent_dismissed.py new file mode 100644 index 00000000..5b6f3de2 --- /dev/null +++ b/documentcloud/addons/migrations/0031_addonevent_dismissed.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.13 on 2026-07-20 21:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("addons", "0030_addonevent_site_indexes"), + ] + + operations = [ + migrations.AddField( + model_name="addonevent", + name="dismissed", + field=models.BooleanField( + default=False, + help_text="If this event has been dismissed from view and should no longer be shown to the user", + verbose_name="dismissed", + ), + ), + ] diff --git a/documentcloud/addons/models.py b/documentcloud/addons/models.py index cfd1a2a6..8f67f312 100644 --- a/documentcloud/addons/models.py +++ b/documentcloud/addons/models.py @@ -601,6 +601,15 @@ class AddOnEvent(models.Model): help_text=_("Timestamp of when the add-on event was last updated"), ) + dismissed = models.BooleanField( + _("dismissed"), + default=False, + help_text=_( + "If this event has been dismissed from view and should no longer be " + "shown to the user" + ), + ) + class Meta: indexes = [ models.Index( diff --git a/documentcloud/addons/serializers.py b/documentcloud/addons/serializers.py index 2a355946..7b02ebb9 100644 --- a/documentcloud/addons/serializers.py +++ b/documentcloud/addons/serializers.py @@ -245,6 +245,7 @@ class Meta: "scratch", "created_at", "updated_at", + "dismissed", ] extra_kwargs = { "addon": {"queryset": AddOn.objects.none()}, diff --git a/documentcloud/addons/tests/test_views.py b/documentcloud/addons/tests/test_views.py index f52f514c..232ee91d 100644 --- a/documentcloud/addons/tests/test_views.py +++ b/documentcloud/addons/tests/test_views.py @@ -438,3 +438,83 @@ def test_filter_message_absent_is_noop(self, client): response = client.get("/api/addon_runs/") assert response.status_code == status.HTTP_200_OK assert len(response.json()["results"]) == 3 + + def test_default_not_dismissed(self): + """A newly created event is not dismissed""" + event = AddOnEventFactory() + assert event.dismissed is False + + def test_dismissed_in_serializer(self, client): + """The dismissed field is exposed on the event""" + event = AddOnEventFactory() + client.force_authenticate(user=event.user) + response = client.get(f"/api/addon_events/{event.pk}/") + assert response.status_code == status.HTTP_200_OK + assert response.json()["dismissed"] is False + + def test_dismiss(self, client): + """The owner can dismiss their event""" + event = AddOnEventFactory(dismissed=False) + client.force_authenticate(user=event.user) + response = client.patch( + f"/api/addon_events/{event.pk}/", {"dismissed": True}, format="json" + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["dismissed"] is True + event.refresh_from_db() + assert event.dismissed is True + + def test_undismiss(self, client): + """The owner can un-dismiss their event""" + event = AddOnEventFactory(dismissed=True) + client.force_authenticate(user=event.user) + response = client.patch( + f"/api/addon_events/{event.pk}/", {"dismissed": False}, format="json" + ) + assert response.status_code == status.HTTP_200_OK + event.refresh_from_db() + assert event.dismissed is False + + def test_dismiss_non_owner(self, client): + """A non-owner can't see the event, so the patch 404s and nothing changes""" + event = AddOnEventFactory(dismissed=False) + other = UserFactory() + client.force_authenticate(user=other) + response = client.patch( + f"/api/addon_events/{event.pk}/", {"dismissed": True}, format="json" + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + event.refresh_from_db() + assert event.dismissed is False + + def test_filter_dismissed_false(self, client): + """?dismissed=false returns only non-dismissed events""" + user = UserFactory() + visible = AddOnEventFactory(user=user, dismissed=False) + AddOnEventFactory(user=user, dismissed=True) + client.force_authenticate(user=user) + response = client.get("/api/addon_events/", {"dismissed": "false"}) + assert response.status_code == status.HTTP_200_OK + ids = [r["id"] for r in response.json()["results"]] + assert ids == [visible.pk] + + def test_filter_dismissed_true(self, client): + """?dismissed=true returns only dismissed events""" + user = UserFactory() + AddOnEventFactory(user=user, dismissed=False) + dismissed = AddOnEventFactory(user=user, dismissed=True) + client.force_authenticate(user=user) + response = client.get("/api/addon_events/", {"dismissed": "true"}) + assert response.status_code == status.HTTP_200_OK + ids = [r["id"] for r in response.json()["results"]] + assert ids == [dismissed.pk] + + def test_filter_dismissed_absent_is_noop(self, client): + """Omitting the dismissed filter returns all viewable events""" + user = UserFactory() + AddOnEventFactory(user=user, dismissed=False) + AddOnEventFactory(user=user, dismissed=True) + client.force_authenticate(user=user) + response = client.get("/api/addon_events/") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["results"]) == 2 diff --git a/documentcloud/addons/views.py b/documentcloud/addons/views.py index 4c2ef374..eb76e8dc 100644 --- a/documentcloud/addons/views.py +++ b/documentcloud/addons/views.py @@ -1043,6 +1043,10 @@ class Filter(django_filters.FilterSet): "`https://www.nifc.gov`." ), ) + dismissed = django_filters.BooleanFilter( + field_name="dismissed", + help_text="Filter events by whether they have been dismissed.", + ) def site_filter(self, queryset, name, value): # pylint: disable=unused-argument diff --git a/documentcloud/documents/processing/info_and_image/requirements.txt b/documentcloud/documents/processing/info_and_image/requirements.txt index c544cb93..bee08a83 100755 --- a/documentcloud/documents/processing/info_and_image/requirements.txt +++ b/documentcloud/documents/processing/info_and_image/requirements.txt @@ -1,6 +1,6 @@ -r cloud-requirements.txt -Pillow==12.1.1 +Pillow==12.3.0 aioboto3==15.5.0 django-environ==0.13.0 furl==2.1.0 diff --git a/documentcloud/documents/processing/ocr/requirements.txt b/documentcloud/documents/processing/ocr/requirements.txt index 232133aa..a53ad4aa 100755 --- a/documentcloud/documents/processing/ocr/requirements.txt +++ b/documentcloud/documents/processing/ocr/requirements.txt @@ -1,6 +1,6 @@ boto3==1.40.61 smart-open==7.5.1 -Pillow==12.1.1 +Pillow==12.3.0 cpuprofile==1.0.1 django-environ==0.13.0 furl==2.1.0 @@ -9,4 +9,4 @@ redis==3.4.1 requests==2.33.0 sentry-sdk==2.57.0 pymupdf==1.25.3 -setuptools<81 \ No newline at end of file +setuptools<81 diff --git a/requirements/base.txt b/requirements/base.txt index 9c09ccc4..4f44033a 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -77,7 +77,7 @@ click-repl==0.3.0 # via celery cpuprofile==1.0.1 # via -r requirements/base.in -cryptography==46.0.7 +cryptography==49.0.0 # via # -r requirements/base.in # google-auth @@ -99,7 +99,7 @@ defusedxml==0.7.1 # social-auth-core deprecated==1.3.1 # via pikepdf -django==5.2.13 +django==5.2.15 # via # -r requirements/base.in # daily-active-users @@ -272,8 +272,9 @@ logzio-python-handler==4.1.9 # via -r requirements/base.in luqum==0.8.1 # via -r requirements/base.in -lxml==6.0.2 +lxml==6.1.1 # via + # -r requirements/base.in # pikepdf # premailer markdown==3.8.1 @@ -322,7 +323,7 @@ pickleshare==0.7.5 # via ipython pikepdf==10.5.1 # via -r requirements/base.in -pillow==12.1.1 +pillow==12.3.0 # via # -r requirements/base.in # pdfplumber @@ -368,7 +369,7 @@ pycparser==2.19 # via cffi pygments==2.20.0 # via ipython -pyjwt==2.12.1 +pyjwt==2.13.0 # via # djangorestframework-simplejwt # social-auth-core @@ -477,7 +478,7 @@ unidecode==1.1.1 # via -r requirements/base.in uritemplate==4.1.1 # via drf-spectacular -urllib3==2.6.3 +urllib3==2.7.0 # via # botocore # requests diff --git a/requirements/local.txt b/requirements/local.txt index 3482d582..b4f1a3a1 100644 --- a/requirements/local.txt +++ b/requirements/local.txt @@ -141,7 +141,7 @@ coverage==4.5.4 # django-coverage-plugin cpuprofile==1.0.1 # via -r requirements/base.txt -cryptography==46.0.7 +cryptography==49.0.0 # via # -r requirements/base.txt # google-auth @@ -177,7 +177,7 @@ deprecated==1.3.1 # pikepdf dill==0.4.1 # via pylint -django==5.2.13 +django==5.2.15 # via # -r requirements/base.txt # daily-active-users @@ -215,7 +215,7 @@ django-cprofile-middleware==1.0.5 # via -r requirements/base.txt django-debug-toolbar==6.2.0 # via -r requirements/base.txt -django-environ==0.4.5 +django-environ==0.13.0 # via -r requirements/base.txt django-extensions==3.2.3 # via -r requirements/base.txt @@ -423,7 +423,7 @@ logzio-python-handler==4.1.9 # via -r requirements/base.txt luqum==0.8.1 # via -r requirements/base.txt -lxml==6.0.2 +lxml==6.1.1 # via # -r requirements/base.txt # pikepdf @@ -522,7 +522,7 @@ pickleshare==0.7.5 # ipython pikepdf==10.5.1 # via -r requirements/base.txt -pillow==12.1.1 +pillow==12.3.0 # via # -r requirements/base.txt # pdfplumber @@ -613,7 +613,7 @@ pygments==2.20.0 # pytest # rich # sphinx -pyjwt==2.12.1 +pyjwt==2.13.0 # via # -r requirements/base.txt # djangorestframework-simplejwt @@ -831,7 +831,7 @@ uritemplate==4.1.1 # via # -r requirements/base.txt # drf-spectacular -urllib3==2.6.3 +urllib3==2.7.0 # via # -r requirements/base.txt # botocore diff --git a/requirements/production.txt b/requirements/production.txt index e9dd130b..a59122aa 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -120,7 +120,7 @@ click-repl==0.3.0 # celery cpuprofile==1.0.1 # via -r requirements/base.txt -cryptography==46.0.7 +cryptography==49.0.0 # via # -r requirements/base.txt # google-auth @@ -150,7 +150,7 @@ deprecated==1.3.1 # via # -r requirements/base.txt # pikepdf -django==5.2.13 +django==5.2.15 # via # -r requirements/base.txt # daily-active-users @@ -194,7 +194,7 @@ django-cprofile-middleware==1.0.5 # via -r requirements/base.txt django-debug-toolbar==6.2.0 # via -r requirements/base.txt -django-environ==0.4.5 +django-environ==0.13.0 # via -r requirements/base.txt django-extensions==3.2.3 # via -r requirements/base.txt @@ -329,7 +329,7 @@ grpcio-status==1.63.0rc1 # -r requirements/base.txt # google-api-core # google-cloud-pubsub -gunicorn==20.1.0 +gunicorn==26.0.0 # via -r requirements/production.in html2text==2020.1.16 # via -r requirements/base.txt @@ -373,7 +373,7 @@ logzio-python-handler==4.1.9 # via -r requirements/base.txt luqum==0.8.1 # via -r requirements/base.txt -lxml==6.0.2 +lxml==6.1.1 # via # -r requirements/base.txt # pikepdf @@ -422,6 +422,7 @@ orderedmultidict==1.0.1 packaging==24.1 # via # -r requirements/base.txt + # gunicorn # pikepdf parso==0.8.3 # via @@ -445,7 +446,7 @@ pickleshare==0.7.5 # ipython pikepdf==10.5.1 # via -r requirements/base.txt -pillow==12.1.1 +pillow==12.3.0 # via # -r requirements/base.txt # pdfplumber @@ -515,7 +516,7 @@ pygments==2.20.0 # via # -r requirements/base.txt # ipython -pyjwt==2.12.1 +pyjwt==2.13.0 # via # -r requirements/base.txt # djangorestframework-simplejwt @@ -656,7 +657,7 @@ uritemplate==4.1.1 # via # -r requirements/base.txt # drf-spectacular -urllib3==2.6.3 +urllib3==2.7.0 # via # -r requirements/base.txt # botocore diff --git a/tasks.py b/tasks.py index 8f999993..c5d28f44 100755 --- a/tasks.py +++ b/tasks.py @@ -14,6 +14,16 @@ ) WEB_OPEN = "xdg-open {} > /dev/null 2>&1" +@task +def up(c): + """Start the docker images""" + c.run("docker compose up -d") + + +@task +def down(c): + """Shut down the docker images""" + c.run(f"docker compose down") @task def test(