diff --git a/pontoon/base/admin.py b/pontoon/base/admin.py index 80fd6765d8..bddc893eea 100644 --- a/pontoon/base/admin.py +++ b/pontoon/base/admin.py @@ -292,6 +292,10 @@ class TranslationAdmin(admin.ModelAdmin): raw_id_fields = ("entity",) +class ProjectSlugHistoryAdmin(admin.ModelAdmin): + readonly_fields = ("created_at",) + + class CommentAdmin(admin.ModelAdmin): raw_id_fields = ("translation", "entity") @@ -367,3 +371,4 @@ def performed_by_email(self, obj): admin.site.register(models.ChangedEntityLocale, ChangedEntityLocaleAdmin) admin.site.register(models.PermissionChangelog, UserRoleLogActionAdmin) admin.site.register(models.Comment, CommentAdmin) +admin.site.register(models.ProjectSlugHistory, ProjectSlugHistoryAdmin) diff --git a/pontoon/base/migrations/0046_projectslughistory.py b/pontoon/base/migrations/0046_projectslughistory.py new file mode 100644 index 0000000000..92fd763665 --- /dev/null +++ b/pontoon/base/migrations/0046_projectslughistory.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2.15 on 2023-06-21 18:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("base", "0045_drop_project_links_url_width"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectSlugHistory", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("old_slug", models.SlugField(max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="base.project" + ), + ), + ], + ), + ] diff --git a/pontoon/base/models.py b/pontoon/base/models.py index 0720ed1b42..43f83cf934 100644 --- a/pontoon/base/models.py +++ b/pontoon/base/models.py @@ -1557,6 +1557,12 @@ def available_locales_list(self): return list(self.locales.all().values_list("code", flat=True)) +class ProjectSlugHistory(models.Model): + project = models.ForeignKey("Project", on_delete=models.CASCADE) + old_slug = models.SlugField(max_length=255) + created_at = models.DateTimeField(auto_now_add=True) + + class UserProfile(models.Model): # This field is required. user = models.OneToOneField( @@ -3070,6 +3076,7 @@ def map_entities( "translation": translation_array, "readonly": entity.resource.project.projectlocale[0].readonly, "is_sibling": is_sibling, + "date_created": entity.date_created, } ) diff --git a/pontoon/base/signals.py b/pontoon/base/signals.py index ca1add91ae..5935c548a9 100644 --- a/pontoon/base/signals.py +++ b/pontoon/base/signals.py @@ -13,6 +13,7 @@ ProjectLocale, TranslatedResource, UserProfile, + ProjectSlugHistory, ) @@ -206,3 +207,19 @@ def assign_project_locale_group_permissions(sender, **kwargs): def create_user_profile(sender, instance, created, **kwargs): if created: UserProfile.objects.create(user=instance) + + +@receiver(pre_save, sender=Project) +def create_slug_history(sender, instance, **kwargs): + """ + Signal receiver that, prior to saving a Project instance, creates a ProjectSlugHistory object if the project's slug has changed. + """ + if instance.pk: # checks if instance is not a new object + try: + old_instance = sender.objects.get(pk=instance.pk) + if old_instance.slug != instance.slug: + ProjectSlugHistory.objects.create( + project=instance, old_slug=old_instance.slug + ) + except sender.DoesNotExist: + pass diff --git a/pontoon/base/tests/models/test_entity.py b/pontoon/base/tests/models/test_entity.py index 4579d86180..f895f79f96 100644 --- a/pontoon/base/tests/models/test_entity.py +++ b/pontoon/base/tests/models/test_entity.py @@ -327,6 +327,7 @@ def test_entity_project_locale_no_paths( "machinery_original": str(entity_a.string), "readonly": False, "is_sibling": False, + "date_created": entity_a.date_created, } assert entities[0] == expected diff --git a/pontoon/base/tests/test_utils.py b/pontoon/base/tests/test_utils.py index 5847bfaadc..e32295be98 100644 --- a/pontoon/base/tests/test_utils.py +++ b/pontoon/base/tests/test_utils.py @@ -2,6 +2,9 @@ from django.contrib.auth import get_user_model +from django.urls import reverse +from django.urls.exceptions import NoReverseMatch + from pontoon.base.models import Project from pontoon.base.utils import ( aware_datetime, @@ -13,6 +16,274 @@ is_email, ) +from pontoon.test.factories import ( + ProjectFactory, + ResourceFactory, + LocaleFactory, + ProjectSlugHistoryFactory, + ProjectLocaleFactory, +) + + +@pytest.fixture +def project_d(): + """ + Fixture that sets up and returns a Project with associated Locale and Resource. + """ + locale = LocaleFactory.create() + project = ProjectFactory.create( + name="Project D", slug="project-d", disabled=False, system_project=False + ) + ResourceFactory.create(project=project, path="resource_d.po", format="po") + ProjectLocaleFactory.create(project=project, locale=locale) + return project + + +def create_slug_history_and_change_slug(project, new_slug): + """ + This function is a helper for tests that need to simulate changing a project's slug. + It records the project's current slug in the history, then updates the project's slug + to a new value. + """ + # Record the old slug in the history + ProjectSlugHistoryFactory.create(project=project, old_slug=project.slug) + + # Change the slug of the project to the new_slug + project.slug = new_slug + project.save() + project.refresh_from_db() + + return project + + +@pytest.mark.django_db +def test_project_view_redirects_old_slug(client, project_d): + """ + Test to ensure that accessing a project view with an old slug redirects to the new slug URL. + """ + old_slug = project_d.slug + new_slug = "project-d-new-1" + project_d = create_slug_history_and_change_slug(project_d, new_slug) + + # First access the URL with the new slug and ensure it's working + response = client.get( + reverse("pontoon.projects.project", kwargs={"slug": new_slug}) + ) + assert response.status_code == 200 + + # Now access the URL with the old slug + response = client.get( + reverse("pontoon.projects.project", kwargs={"slug": old_slug}) + ) + # The old slug should cause a redirect to the new slug URL + assert response.status_code == 302 + assert response.url == reverse( + "pontoon.projects.project", kwargs={"slug": new_slug} + ) + + +@pytest.mark.django_db +def test_handle_old_slug_redirect_no_loop(client, project_d): + """ + Test that there is no redirect loop when a project's slug is renamed from 'cc' to 'dd' and then back to 'cc'. + """ + # Rename project from 'cc' to 'dd' and then back to 'cc' + create_slug_history_and_change_slug(project_d, "cc") + create_slug_history_and_change_slug(project_d, "dd") + create_slug_history_and_change_slug(project_d, "cc") + + # Request the project detail view with slug 'cc' + response = client.get(reverse("pontoon.projects.project", kwargs={"slug": "cc"})) + + # Assert that the response is not a redirect (status code is not 302) + assert response.status_code != 302 + + +@pytest.mark.django_db +def test_handle_old_slug_redirect_no_redirect_to_different_project(client, project_d): + """ + Test that a request for a slug that was changed and then reused by a different project does not redirect to the original project. + """ + # Rename project from 'ee' to 'ff' + create_slug_history_and_change_slug(project_d, "ee") + create_slug_history_and_change_slug(project_d, "ff") + + # Create a new project with slug 'ee' + project = ProjectFactory.create( + name="Project E", slug="ee", disabled=False, system_project=False + ) + ResourceFactory.create(project=project, path="resource_e.po", format="po") + + # Request the project detail view with slug 'ee' + response = client.get(reverse("pontoon.projects.project", kwargs={"slug": "ee"})) + + # Assert that the response is successful (status code is 200) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_handle_no_slug_redirect_project(client): + """ + Test to ensure that an attempt to access a project view without a slug raises a NoReverseMatch exception. + """ + with pytest.raises(NoReverseMatch): + # Try to access the URL without a slug + client.get(reverse("pontoon.projects.project", kwargs={})) + + +@pytest.mark.django_db +def test_handle_nonexistent_slug_redirect_project(client): + """ + Test to ensure that an attempt to access a project view with a non-existent slug returns a 404 error. + """ + slug = "nonexistent-slug" + + response = client.get(reverse("pontoon.projects.project", kwargs={"slug": slug})) + + # The expectation here is that the server should return a 404 error + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_translation_view_redirects_old_slug(client, project_d): + """ + Test to ensure that accessing a translation view with an old slug redirects to the new slug URL. + """ + # Add resource to project + resource_path = "resource_d.po" + + old_slug = project_d.slug + new_slug = "project-d-new-2" + locale = project_d.locales.first().code + project_d = create_slug_history_and_change_slug(project_d, new_slug) + + # First access the URL with the new slug and ensure it's working + response = client.get( + reverse( + "pontoon.translate", + kwargs={"project": new_slug, "locale": locale, "resource": resource_path}, + ) + ) + assert response.status_code == 200 + + # Now access the URL with the old slug + response = client.get( + reverse( + "pontoon.translate", + kwargs={"project": old_slug, "locale": locale, "resource": resource_path}, + ) + ) + # The old slug should cause a redirect to the new slug URL + assert response.status_code == 302 + assert response.url == reverse( + "pontoon.translate", + kwargs={"project": new_slug, "locale": locale, "resource": resource_path}, + ) + + +@pytest.mark.django_db +def test_handle_no_slug_redirect_translate(client, project_d): + """ + Test to ensure that an attempt to access a translate view without a slug raises a NoReverseMatch exception. + """ + locale = project_d.locales.first().code + resource_path = "resource_d.po" + + with pytest.raises(NoReverseMatch): + client.get( + reverse( + "pontoon.translate", + kwargs={"locale": locale, "resource": resource_path}, + ) + ) + + +@pytest.mark.django_db +def test_handle_nonexistent_slug_redirect_translate(client, project_d): + """ + Test to ensure that an attempt to access a translate view with a non-existent slug returns a 404 error. + """ + locale = project_d.locales.first().code + resource_path = "resource_d.po" + slug = "nonexistent-slug" + + response = client.get( + reverse( + "pontoon.translate", + kwargs={"project": slug, "locale": locale, "resource": resource_path}, + ) + ) + + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_localization_view_redirects_old_slug(client, project_d): + """ + Test to ensure that accessing a localization view with an old slug redirects to the new slug URL. + """ + old_slug = project_d.slug + new_slug = "project-d-new-3" + locale = project_d.locales.first().code + project_d = create_slug_history_and_change_slug(project_d, new_slug) + + # First access the URL with the new slug and ensure it's working + response = client.get( + reverse( + "pontoon.localizations.localization", + kwargs={"slug": new_slug, "code": locale}, + ) + ) + assert response.status_code == 200 + + # Now access the URL with the old slug + response = client.get( + reverse( + "pontoon.localizations.localization", + kwargs={"slug": old_slug, "code": locale}, + ) + ) + # The old slug should cause a redirect to the new slug URL + assert response.status_code == 302 + assert response.url == reverse( + "pontoon.localizations.localization", + kwargs={"slug": new_slug, "code": locale}, + ) + + +@pytest.mark.django_db +def test_handle_no_slug_redirect_localization(client, project_d): + """ + Test to ensure that an attempt to access a localization view without a slug raises a NoReverseMatch exception. + """ + locale = project_d.locales.first().code + + with pytest.raises(NoReverseMatch): + client.get( + reverse( + "pontoon.localizations.localization", + kwargs={"code": locale}, + ) + ) + + +@pytest.mark.django_db +def test_handle_nonexistent_slug_redirect_localization(client, project_d): + """ + Test to ensure that an attempt to access a localization view with a non-existent slug returns a 404 error. + """ + locale = project_d.locales.first().code + slug = "nonexistent-slug" + + response = client.get( + reverse( + "pontoon.localizations.localization", + kwargs={"slug": slug, "code": locale}, + ) + ) + + assert response.status_code == 404 + @pytest.mark.django_db def test_get_m2m_changes_no_change(user_a): diff --git a/pontoon/base/utils.py b/pontoon/base/utils.py index cba3fa44fc..ef8a4e235e 100644 --- a/pontoon/base/utils.py +++ b/pontoon/base/utils.py @@ -21,10 +21,13 @@ from django.db.models import Prefetch, Q from django.db.models.query import QuerySet from django.http import HttpResponseBadRequest -from django.shortcuts import get_object_or_404 from django.utils import timezone from django.utils.text import slugify from django.utils.translation import trans_real +from django.shortcuts import redirect, get_object_or_404 +from django.apps import apps +from django.urls import reverse +from django.http import Http404 UNUSABLE_SEARCH_CHAR = "☠" @@ -606,3 +609,33 @@ def is_email(email): return True except ValidationError: return False + + +def get_project_or_redirect( + slug, redirect_view_name, slug_arg_name, request_user, **kwargs +): + """ + Attempts to get a project with the given slug. If the project doesn't exist, it checks if the slug is in the + ProjectSlugHistory and if so, it redirects to the current project slug URL. If the old slug is not found in the + history, it raises an Http404 error. + """ + Project = apps.get_model("base", "Project") + ProjectSlugHistory = apps.get_model("base", "ProjectSlugHistory") + try: + project = get_object_or_404( + Project.objects.visible_for(request_user).available(), slug=slug + ) + return project + except Http404: + slug_history = ( + ProjectSlugHistory.objects.filter(old_slug=slug) + .order_by("-created_at") + .first() + ) + if slug_history is not None: + redirect_kwargs = {slug_arg_name: slug_history.project.slug} + redirect_kwargs.update(kwargs) + redirect_url = reverse(redirect_view_name, kwargs=redirect_kwargs) + return redirect(redirect_url) + else: + raise Http404 diff --git a/pontoon/localizations/views.py b/pontoon/localizations/views.py index 1e13d74654..b1f5e3c052 100644 --- a/pontoon/localizations/views.py +++ b/pontoon/localizations/views.py @@ -3,7 +3,7 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.db.models import Q -from django.http import Http404 +from django.http import Http404, HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.views.generic.detail import DetailView @@ -13,7 +13,7 @@ ProjectLocale, TranslatedResource, ) -from pontoon.base.utils import require_AJAX +from pontoon.base.utils import require_AJAX, get_project_or_redirect from pontoon.contributors.views import ContributorsMixin from pontoon.insights.utils import get_insights from pontoon.tags.utils import TagsTool @@ -22,9 +22,13 @@ def localization(request, code, slug): """Locale-project overview.""" locale = get_object_or_404(Locale, code=code) - project = get_object_or_404( - Project.objects.visible_for(request.user).available(), slug=slug + + project = get_project_or_redirect( + slug, "pontoon.localizations.localization", "slug", request.user, code=code ) + if isinstance(project, HttpResponseRedirect): + return project + project_locale = get_object_or_404( ProjectLocale, locale=locale, diff --git a/pontoon/projects/views.py b/pontoon/projects/views.py index 8841b244df..688afe058d 100644 --- a/pontoon/projects/views.py +++ b/pontoon/projects/views.py @@ -6,7 +6,7 @@ from django.core.exceptions import ImproperlyConfigured from django.db import transaction from django.db.models import Q -from django.http import Http404, JsonResponse +from django.http import Http404, JsonResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.views.generic.detail import DetailView @@ -15,7 +15,7 @@ from notifications.signals import notify from pontoon.base.models import Project, Locale -from pontoon.base.utils import require_AJAX, split_ints +from pontoon.base.utils import require_AJAX, split_ints, get_project_or_redirect from pontoon.contributors.views import ContributorsMixin from pontoon.insights.utils import get_insights from pontoon.projects import forms @@ -43,9 +43,11 @@ def projects(request): def project(request, slug): """Project dashboard.""" - project = get_object_or_404( - Project.objects.visible_for(request.user).available(), slug=slug + project = get_project_or_redirect( + slug, "pontoon.projects.project", "slug", request.user ) + if isinstance(project, HttpResponseRedirect): + return project project_locales = project.project_locale chart = project diff --git a/pontoon/test/factories.py b/pontoon/test/factories.py index 12cc57a6f5..b745d1fe3e 100644 --- a/pontoon/test/factories.py +++ b/pontoon/test/factories.py @@ -17,6 +17,7 @@ TranslatedResource, Translation, TranslationMemoryEntry, + ProjectSlugHistory, ) from pontoon.checks.models import Error, Warning from pontoon.tags.models import Tag @@ -201,3 +202,11 @@ class TermTranslationFactory(DjangoModelFactory): class Meta: model = TermTranslation + + +class ProjectSlugHistoryFactory(DjangoModelFactory): + project = SubFactory(ProjectFactory) + old_slug = Sequence(lambda n: f"old-slug-{n}") + + class Meta: + model = ProjectSlugHistory diff --git a/pontoon/translate/views.py b/pontoon/translate/views.py index 56e3496a71..5d7cf906ac 100644 --- a/pontoon/translate/views.py +++ b/pontoon/translate/views.py @@ -1,6 +1,6 @@ from django.conf import settings from django.contrib import messages -from django.http import Http404 +from django.http import Http404, HttpResponseRedirect from django.shortcuts import ( get_object_or_404, render, @@ -12,9 +12,10 @@ from pontoon.base.models import ( Locale, - Project, ) +from pontoon.base.utils import get_project_or_redirect + @csrf_exempt def catchall_dev(request, context=None): @@ -45,13 +46,19 @@ def translate(request, locale, project, resource): # Validate Project if project.lower() != "all-projects": - project = get_object_or_404( - Project.objects.visible_for(request.user).available(), slug=project + project = get_project_or_redirect( + project, + "pontoon.translate", + "project", + request.user, + locale=locale.code, + resource=resource, ) - - # Validate ProjectLocale - if locale not in project.locales.all(): - raise Http404 + if isinstance(project, HttpResponseRedirect): + return project + # Validate ProjectLocale + if locale not in project.locales.all(): + raise Http404 context = { "locale": get_preferred_locale(request), diff --git a/translate/src/api/entity.ts b/translate/src/api/entity.ts index af6fb571e6..0cf967de99 100644 --- a/translate/src/api/entity.ts +++ b/translate/src/api/entity.ts @@ -30,6 +30,7 @@ export type Entity = { readonly translation: Array; readonly readonly: boolean; readonly isSibling: boolean; + readonly date_created: string; }; /** diff --git a/translate/src/modules/entitydetails/components/Metadata.tsx b/translate/src/modules/entitydetails/components/Metadata.tsx index b4a4c5798e..a1edd148c9 100644 --- a/translate/src/modules/entitydetails/components/Metadata.tsx +++ b/translate/src/modules/entitydetails/components/Metadata.tsx @@ -86,6 +86,36 @@ function GroupComment({ comment }: { comment: string }) { ); } +function EntityCreatedDate({ dateCreated }: { dateCreated: string }) { + // Create date and time formatters + const dateFormatter = new Intl.DateTimeFormat('en-US', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + const timeFormatter = new Intl.DateTimeFormat('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + + // Create a Date object from the dateCreated string + const date = new Date(dateCreated); + + // Format the date and time + const formattedDate = dateFormatter.format(date); + const formattedTime = timeFormatter.format(date); + + // Combine the formatted date and time into one string + const formattedDateTime = `${formattedDate} ${formattedTime}`; + + return ( + + {formattedDateTime} + + ); +} + function ResourceComment({ comment }: { comment: string }) { const ref = React.useRef(null); const [overflow, setOverflow] = React.useState(false); @@ -233,6 +263,7 @@ export function Metadata({ + {Array.isArray(entity.source) ? ( ) : entity.source ? (