Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1102ff5
obsolete resources and corresponding entities
functionzz Feb 13, 2026
efacbf2
fix tests
functionzz Feb 13, 2026
0de52d5
Update pontoon/base/models/entity.py
functionzz Feb 13, 2026
c36236f
add .current to TranslatedResourceQueryset + ResourceQueryset
functionzz Feb 17, 2026
7e072dd
remove useless .current() calls + rename obsolete() to mark_as_obsole…
functionzz Feb 20, 2026
62971b2
run make format
functionzz Feb 20, 2026
79a85a5
add tests for contributor data + translate link
functionzz Feb 25, 2026
1a52cd7
switch kl to tlh
functionzz Feb 27, 2026
0321a49
remove EntityQueryset.obsolete, replace with inline .update
functionzz Feb 27, 2026
4bbcca6
revert .po change
functionzz Feb 27, 2026
18cfadc
Update pontoon/base/tests/managers/test_user.py
functionzz Feb 27, 2026
7930f41
Update pontoon/sync/tests/test_entities.py
functionzz Feb 27, 2026
31906de
run make format
functionzz Feb 27, 2026
6bdbf6d
add TranslatedResource checks in test
functionzz Feb 27, 2026
39d460d
add obsoletion filter for stats_data
functionzz Mar 17, 2026
72668e1
obsolete entity cannot be translated
functionzz Mar 31, 2026
00cc4c6
de-obsolete Resource, delete TranslatedResources upon Resource obsole…
functionzz Apr 17, 2026
5999176
remove TranslatedResource.objects.current()
functionzz Apr 17, 2026
bdc4e7c
update resource de-obsoletion
functionzz Apr 21, 2026
2c28b0d
refactor entities.py, include de-obsoletion logic in update_resources()
functionzz May 1, 2026
b20a371
Squash with main
functionzz May 4, 2026
b94fdc4
Merge branch 'main' into obsolete_resources_over_deletion
functionzz May 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions pontoon/administration/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,9 @@ def __init__(self, *args, **kwargs):
# If the project instance is available, filter resources for this project
if kwargs.get("instance") and kwargs["instance"].project:
project = kwargs["instance"].project
self.fields["resources"].queryset = Resource.objects.filter(
project=project
).select_related()
self.fields["resources"].queryset = (
Resource.objects.current().filter(project=project).select_related()
)


TagInlineFormSet = inlineformset_factory(Project, Tag, form=TagInlineForm, extra=1)
26 changes: 26 additions & 0 deletions pontoon/administration/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,32 @@ def test_manage_project_strings_download_csv(client_superuser):
assert "Mächt’ge".encode() in response.content


@pytest.mark.django_db
def test_manage_project_translate_link_excludes_obsolete_resources(client_superuser):
"""Test that translate_locale is only set when non-obsolete resources exist."""
locale_kl = LocaleFactory.create(code="tlh", name="Klingon")
project = ProjectFactory.create(
data_source=Project.DataSource.DATABASE,
locales=[locale_kl],
repositories=[],
)

# add obsolete resource
ResourceFactory.create(project=project, obsolete=True)

url = reverse("pontoon.admin.project", args=(project.slug,))
response = client_superuser.get(url)
assert response.status_code == 200
assert "translate_locale" not in response.context

# add non-obsolete resource
ResourceFactory.create(project=project, obsolete=False)

response = client_superuser.get(url)
assert response.status_code == 200
assert response.context["translate_locale"] == "tlh"


@pytest.mark.django_db
def test_project_add_locale(client_superuser):
locale_kl = LocaleFactory.create(code="kl", name="Klingon")
Expand Down
6 changes: 3 additions & 3 deletions pontoon/administration/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ def manage_project(request, slug=None, template="admin_project.html"):
}

# Set locale in Translate link
if Resource.objects.filter(project=project).exists() and locales_selected:
if Resource.objects.current().filter(project=project).exists() and locales_selected:
locale = (
utils.get_project_locale_from_request(request, project.locales)
or locales_selected[0].code
Expand Down Expand Up @@ -373,7 +373,7 @@ def _get_resource_for_database_project(project):

"""
try:
return Resource.objects.get(
return Resource.objects.current().get(
project=project,
)
except Resource.DoesNotExist:
Expand Down Expand Up @@ -492,7 +492,7 @@ def manage_project_strings(request, slug=None):
# Get all strings, find the ones that changed, update them in the database.
formset = EntityFormSet(request.POST, queryset=entities)
if formset.is_valid():
resource = Resource.objects.filter(project=project).first()
resource = Resource.objects.current().filter(project=project).first()
entity_max_order = entities.aggregate(Max("order"))["order__max"]
try:
# This line can purposefully cause an exception, and that
Expand Down
6 changes: 5 additions & 1 deletion pontoon/base/models/locale.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,16 @@ def available(self):

def stats_data(self, project=None):
if project is not None:
query = self.filter(translatedresources__resource__project=project)
query = self.filter(
translatedresources__resource__project=project,
translatedresources__resource__obsolete=False,
)
else:
query = self.filter(
translatedresources__resource__project__disabled=False,
translatedresources__resource__project__system_project=False,
translatedresources__resource__project__visibility="public",
translatedresources__resource__obsolete=False,
)

return query.annotate(
Expand Down
4 changes: 3 additions & 1 deletion pontoon/base/models/project_locale.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ def visible(self):
def stats_data(self, project=None, locale=None):
if project:
query = self.filter(
locale__translatedresources__resource__project=project
locale__translatedresources__resource__project=project,
locale__translatedresources__resource__obsolete=False,
).prefetch_related("locale")
tr = "locale__translatedresources"
elif locale:
Expand All @@ -41,6 +42,7 @@ def stats_data(self, project=None, locale=None):
project__disabled=False,
project__system_project=False,
project__visibility="public",
project__resources__obsolete=False,
).prefetch_related("project")
tr = "project__resources__translatedresources"
return query.annotate(
Expand Down
24 changes: 24 additions & 0 deletions pontoon/base/models/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,28 @@
from django.utils import timezone


class ResourceQuerySet(models.QuerySet):
def mark_as_obsolete(self, now=None):
from pontoon.base.models.entity import Entity

if now is None:
now = timezone.now()

self.update(obsolete=True, date_obsoleted=now)
Entity.objects.filter(resource__in=self).update(
obsolete=True,
date_obsoleted=now,
section=None,
)

from pontoon.base.models.translated_resource import TranslatedResource

TranslatedResource.objects.filter(resource__in=self).delete()

def current(self):
return self.filter(obsolete=False)


class Resource(models.Model):
project = models.ForeignKey("Project", models.CASCADE, related_name="resources")
path = models.TextField() # Path to localization file
Expand Down Expand Up @@ -42,6 +64,8 @@ class Format(models.TextChoices):

deadline = models.DateField(blank=True, null=True)

objects = ResourceQuerySet.as_manager()

# Formats that allow empty translations
EMPTY_TRANSLATION_FORMATS = {
Format.DTD,
Expand Down
4 changes: 2 additions & 2 deletions pontoon/base/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ def add_locale_to_system_projects(sender, instance, created, **kwargs):
projects = Project.objects.filter(system_project=True)
for project in projects:
ProjectLocale.objects.create(project=project, locale=instance)
for resource in project.resources.all():
for resource in project.resources.current():
translated_resource = TranslatedResource.objects.create(
resource=resource,
locale=instance,
Expand All @@ -189,7 +189,7 @@ def add_locale_to_terminology_project(sender, instance, created, **kwargs):
if created:
project = Project.objects.get(slug="terminology")
ProjectLocale.objects.create(project=project, locale=instance)
for resource in project.resources.all():
for resource in project.resources.current():
translated_resource = TranslatedResource.objects.create(
resource=resource,
locale=instance,
Expand Down
66 changes: 66 additions & 0 deletions pontoon/base/tests/managers/test_user.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from unittest.mock import MagicMock

import pytest

from django.db.models import Q
from django.utils import timezone

from pontoon.base.utils import aware_datetime
from pontoon.contributors.utils import users_with_translations_counts
Expand Down Expand Up @@ -322,3 +325,66 @@ def test_mgr_user_query_args_filtering(
assert top_contribs[0].translations_approved_count == 11
assert top_contribs[0].translations_rejected_count == 0
assert top_contribs[0].translations_unapproved_count == 3


@pytest.mark.django_db
def test_mgr_user_translation_counts_after_resource_removed(
resource_a,
locale_a,
):
"""
Tests that contributor translation counts remain unchanged after
a resource is removed via remove_resources.

Translation counts should include translations from obsolete resources
since they represent the contributor's historical work.
"""
from pontoon.sync.core.entities import remove_resources

contributor = UserFactory.create()
entities = EntityFactory.create_batch(size=12, resource=resource_a)

batch_kwargs = (
[dict(approved=True)] * 7
+ [dict(approved=False, fuzzy=False, rejected=True)] * 3
+ [dict(fuzzy=True)] * 2
)

for i, kwa in enumerate(batch_kwargs):
TranslationFactory.create(
locale=locale_a,
user=contributor,
entity=entities[i],
approved=kwa.get("approved", False),
rejected=kwa.get("rejected", False),
fuzzy=kwa.get("fuzzy", False),
)

top_contribs = users_with_translations_counts()
assert len(top_contribs) == 1
assert top_contribs[0] == contributor
assert top_contribs[0].translations_count == 12
assert top_contribs[0].translations_approved_count == 7
assert top_contribs[0].translations_rejected_count == 3
assert top_contribs[0].translations_unapproved_count == 2

# Remove resource using remove_resources (simulates sync removing source file)
checkout = MagicMock()
checkout.path = "/path_1"
checkout.removed = [resource_a.path]

paths = MagicMock()
paths.ref_root = "/path_1"

remove_resources(resource_a.project, paths, checkout, timezone.now())

resource_a.refresh_from_db()
assert resource_a.obsolete is True

top_contribs = users_with_translations_counts()
assert len(top_contribs) == 1
assert top_contribs[0] == contributor
assert top_contribs[0].translations_count == 12
assert top_contribs[0].translations_approved_count == 7
assert top_contribs[0].translations_rejected_count == 3
assert top_contribs[0].translations_unapproved_count == 2
4 changes: 1 addition & 3 deletions pontoon/projects/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,7 @@ def projects(request):
"projects/projects.html",
{
"projects": projects,
"all_projects_stats": TranslatedResource.objects.all().string_stats(
request.user
),
"all_projects_stats": TranslatedResource.objects.string_stats(request.user),
"project_stats": project_stats,
"top_instances": get_top_instances(projects, project_stats),
},
Expand Down
51 changes: 44 additions & 7 deletions pontoon/sync/core/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def sync_resources_from_repo(

with transaction.atomic():
renamed_paths = rename_resources(project, paths, checkout)
removed_paths = remove_resources(project, paths, checkout)
removed_paths = remove_resources(project, paths, checkout, now)
old_res_added_ent_count, changed_paths = update_resources(project, updates, now)
new_res_added_ent_count, _ = add_resources(project, updates, changed_paths, now)
update_translated_resources(project, locale_map, paths)
Expand Down Expand Up @@ -103,7 +103,10 @@ def rename_resources(


def remove_resources(
project: Project, paths: L10nConfigPaths | L10nDiscoverPaths, checkout: Checkout
project: Project,
paths: L10nConfigPaths | L10nDiscoverPaths,
checkout: Checkout,
now: datetime,
) -> set[str]:
if not checkout.removed:
return set()
Expand All @@ -115,8 +118,7 @@ def remove_resources(
)
removed_db_paths = {res.path for res in removed_resources}
if removed_db_paths:
# FIXME: https://github.com/mozilla/pontoon/issues/2133
removed_resources.delete()
removed_resources.mark_as_obsolete(now)
rm_count = len(removed_db_paths)
str_source_files = "source file" if rm_count == 1 else "source files"
log.info(
Expand Down Expand Up @@ -279,11 +281,46 @@ def add_resources(
changed_paths: set[str],
now: datetime,
) -> tuple[int, set[str]]:

existing_resources = dict(
Resource.objects.filter(project=project, path__in=updates.keys()).values_list(
"path", "obsolete"
)
)

valid_updates = {}
deobsoletion_paths = []

for db_path, res in updates.items():
res_exists = db_path in existing_resources
res_obsolete = res_exists and existing_resources[db_path]

has_entries = False
if not res_obsolete:
has_entries = next(res.all_entries(), None) is not None

if (has_entries and db_path not in changed_paths) or res_obsolete:
valid_updates[db_path] = res

if res_obsolete:
deobsoletion_paths.append(db_path)

if not valid_updates:
return 0, set()

# Resource de-obsoletion for existing Resources that are added back in
# TODO Entity de-obsoletion needs to accompany Resource de-obsoletion
if deobsoletion_paths:
Resource.objects.filter(project=project, path__in=deobsoletion_paths).update(
obsolete=False
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this work happening here in add_resources(), and not in update_resources()? That seems like a better place where to update resources.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've thought about that, and the reason is while the above code snippet is definitely an update, the add_resources function needs to be responsible in detecting resources that exist that are added back in (i.e de-obsoletion). If I put the de-obsoletion logic in update_resources(), I would need to duplicate the valid_updates logic into add_resources() anyway to filter out the de-obsoleted resources.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the de-obsoletion happens in update_resources(), won't the de-obsoleted resources be included in changed_paths? I still don't see why any of the code in add_resources() needs to change.


new_resources = [
Resource(project=project, path=db_path, format=get_res_format(res))
for db_path, res in updates.items()
if next(res.all_entries(), None) and db_path not in changed_paths
for db_path, res in valid_updates.items()
if db_path not in existing_resources
]

if not new_resources:
return 0, set()

Expand Down Expand Up @@ -345,7 +382,7 @@ def update_translated_resources(
.iterator()
)
add_tr: list[TranslatedResource] = []
for resource in Resource.objects.filter(project=project).iterator():
for resource in Resource.objects.current().filter(project=project).iterator():
_, locales = paths.target(resource.path)
for lc in locales:
locale = locale_map.get(lc, None)
Expand Down
6 changes: 3 additions & 3 deletions pontoon/sync/core/translations_from_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,9 @@ def find_db_updates(

resources: dict[str, Resource] = {
res.path: res
for res in Resource.objects.filter(
project=project, path__in=resource_paths
).iterator()
for res in Resource.objects.current()
Comment thread
functionzz marked this conversation as resolved.
.filter(project=project, path__in=resource_paths)
.iterator()
}

# Exclude translations for which DB & repo already match
Expand Down
Loading
Loading