Skip to content
1 change: 1 addition & 0 deletions .isort.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ skip=
skip_glob=*/migrations/*
known_future_library=future
known_django=django,celery,rest_framework
known_third_party=pysolr
known_first_party=documentcloud
indent=' '
multi_line_output=3
Expand Down
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,23 @@ You must first have these set up and ready to go:
12. Go to [Django admin for DocumentCloud](https://api.dev.documentcloud.org/admin) and add the required static [flat page](https://api.dev.documentcloud.org/admin/flatpages/flatpage/) called `/tipofday/`. It can be blank. Do not prefix the URL with `/pages/`. Specifying the `Site` as `example.com` is alright.
13. Create an initial Minio bucket to simulate AWS S3 locally:
- Run `inv initialize-minio`
14. Upload a document:
14. Download and setup Tesseract language data locally so that OCR works.
- From within documentcloud/documents/processing/ocr/tesseract/tessdata/ run the following to download the trained data for the English language:
```curl -L -o eng.traineddata https://github.com/tesseract-ocr/tessdata_fast/raw/main/eng.traineddata```
- You'll also need to install the orientation and script detection data:
```curl -L -o osd.traineddata https://github.com/tesseract-ocr/tessdata_fast/raw/main/osd.traineddata```
- Lastly, you need to use the invoke command `inv manage upload_languages` to take the trained data from that directory and
place it in the correct location in the Minio bucket to test OCR in a local development environment. You may add additional languages as needed by modifying the
curl command for the language data above.
15. Upload a document:
- **Check your memory allocation on Docker is at least 7gb.** A sign that you do not have enough memory allocated is if containers are randomly failing or if your system is swapping heavily, especially when uploading documents.
- The "upload" button should not be grayed out (if it is, check your user organization Verified Journalist status above)
- If you get an error on your console about signatures, fix minio as above.
- If you get an error on your console about tipofday not found, add the static page as above.
15. Develop DocumentCloud and its frontend!
16. You can run the tests with `inv test`.
16. Develop DocumentCloud and its frontend!
17. Tests can be run using `inv test`. If a test doesn't exist for new functionality, please create tests for the new functionality to ensure test coverage.
Before submitting a pull request, ensure all code passes quality and formatting checks by running `inv format` and `inv pylint` and resolving any issues.


- If you want to run a subset of the tests, you can specify the directory containing the test you want with the `path` switch like so: `inv test --path documentcloud/documents`.
- You can specify a single file in `--path` if you only want to run the tests in that file.
Expand Down
22 changes: 22 additions & 0 deletions documentcloud/addons/migrations/0031_addonevent_dismissed.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
9 changes: 9 additions & 0 deletions documentcloud/addons/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions documentcloud/addons/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ class Meta:
"scratch",
"created_at",
"updated_at",
"dismissed",
]
extra_kwargs = {
"addon": {"queryset": AddOn.objects.none()},
Expand Down
80 changes: 80 additions & 0 deletions documentcloud/addons/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions documentcloud/addons/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion documentcloud/core/tests.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Django
from django.conf import settings
from django.contrib.flatpages.models import FlatPage
from django.contrib.sites.models import Site
from django.db import transaction
Expand Down Expand Up @@ -58,7 +59,7 @@ def sign(self, data):
token = "testtoken"
timestamp = int(time.time())
signature = hmac.new(
key="".encode("utf8"),
key=settings.MAILGUN_API_KEY.encode("utf8"),
msg=f"{timestamp}{token}".encode("utf8"),
digestmod=hashlib.sha256,
).hexdigest()
Expand Down
1 change: 0 additions & 1 deletion documentcloud/documents/models/saved_search.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# Standard Library
# Django
from django.db import models
from django.utils.translation import gettext_lazy as _
Expand Down
1 change: 0 additions & 1 deletion documentcloud/documents/modifications.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# Standard Library
# Django
from django.db import transaction

Expand Down
30 changes: 17 additions & 13 deletions documentcloud/organizations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,10 @@ def has_admin(self, user):
return self.users.filter(pk=user.pk, memberships__admin=True).exists()

def _update_resources(self, data, date_update):
# calc reqs/month in case it has changed
self.ai_credits_per_month = self.calc_ai_credits_per_month(data["max_users"])
# sum AI credits across all entitlements
self.ai_credits_per_month = sum(
self._calc_ent_ai_credits(ent_data) for ent_data in data["entitlements"]
)

# if date update has changed, then this is a monthly restore of the
# subscription, and we should restore monthly AI credits. If not, AI credits
Expand All @@ -109,8 +111,19 @@ def _update_resources(self, data, date_update):
self.monthly_ai_credits = self.ai_credits_per_month
self.date_update = date_update

def _choose_entitlement(self, entitlements):
return max(entitlements, key=lambda e: e["resources"].get("base_ai_credits", 0))
@staticmethod
def _calc_ent_ai_credits(ent_data):
"""Compute the AI credit quota for one entitlement entry.

Uses ent_data["quantity"] (the per-subscription billing quantity sent
by Squarelet) rather than the stale org-level max_users field.
"""
r = ent_data["resources"]
base = r.get("base_ai_credits", 0)
per_user = r.get("ai_credits_per_user", 0)
minimum = r.get("minimum_users", 1)
quantity = ent_data.get("quantity", 1)
return base + max(0, quantity - minimum) * per_user

@transaction.atomic
def merge(self, uuid):
Expand Down Expand Up @@ -145,15 +158,6 @@ def merge(self, uuid):

self.merged = other

def calc_ai_credits_per_month(self, users):
"""Calculate how many AI credits an organization gets per month on this plan
for a given number of users"""
return (
self.entitlement.base_ai_credits
+ (users - self.entitlement.minimum_users)
* self.entitlement.ai_credits_per_user
)

@transaction.atomic
def use_ai_credits(self, amount, user_id, note):
"""Try to deduct AI credits from the organization's balance
Expand Down
Loading
Loading