src/wagtail_localize_smartling/models.py (313 lines of code) (raw):
import hashlib
import logging
from collections.abc import Iterable
from datetime import datetime
from functools import lru_cache
from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
from django.db import models
from django.db.models.manager import Manager
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from wagtail.admin.panels import FieldPanel
from wagtail.models import Locale, Page
from wagtail_localize.components import register_translation_component
from wagtail_localize.models import Translation, TranslationSource
from wagtail_localize.tasks import ImmediateBackend, background
from .api.client import client
from .api.types import JobStatus
from .constants import UNSYNCED_OR_PENDING_STATUSES
from .forms import JobForm
from .settings import settings as smartling_settings
from .sync import sync_job
from .utils import compute_content_hash, get_snippet_admin_url
logger = logging.getLogger(__name__)
class SyncedModel(models.Model):
first_synced_at = models.DateTimeField(null=True, editable=False)
last_synced_at = models.DateTimeField(null=True, editable=False)
class Meta:
ordering = (models.F("first_synced_at").desc(nulls_first=True), "-pk")
abstract = True
class Project(SyncedModel):
"""
Represents a project in Smartling. There should normally only be one of
these, it's synced from the Smartling API based on the PROJECT_ID setting.
"""
environment = models.CharField(max_length=16)
account_uid = models.CharField(max_length=32)
archived = models.BooleanField()
project_id = models.CharField(max_length=16)
name = models.CharField(max_length=255)
type_code = models.CharField(max_length=32)
source_locale_description = models.CharField(max_length=255)
source_locale_id = models.CharField(max_length=16)
target_locales: Manager["ProjectTargetLocale"]
class Meta(SyncedModel.Meta):
unique_together = ["environment", "account_uid", "project_id"]
constraints = [
models.CheckConstraint(
check=models.Q(environment="production")
| models.Q(environment="staging"),
name="project_environment",
)
]
def __str__(self):
return f"{self.name} ({self.project_id})"
@classmethod
@lru_cache
def get_current(cls) -> "Project":
"""
Returns the current Project as per the PROJECT_ID setting. The first
time this is called, the project details are fetched from the Smartling
API and a Project instance is created/updated as appropriate before
being returned. Subsequent calls returned that instance from cache.
"""
now = timezone.now()
project_details = client.get_project_details()
try:
project = cls.objects.get(
environment=smartling_settings.ENVIRONMENT,
account_uid=project_details["accountUid"],
project_id=project_details["projectId"],
)
except Project.DoesNotExist:
project = cls(
environment=smartling_settings.ENVIRONMENT,
account_uid=project_details["accountUid"],
project_id=project_details["projectId"],
first_synced_at=now,
)
project.archived = project_details["archived"]
project.name = project_details["projectName"]
project.type_code = project_details["projectTypeCode"]
project.source_locale_description = project_details["sourceLocaleDescription"]
project.source_locale_id = project_details["sourceLocaleId"]
project.last_synced_at = now
project.save()
seen_target_locale_ids: set[str] = set()
for target_locale_data in project_details["targetLocales"]:
seen_target_locale_ids.add(target_locale_data["localeId"])
ProjectTargetLocale.objects.update_or_create(
project=project,
locale_id=target_locale_data["localeId"],
defaults={
"description": target_locale_data["description"],
"enabled": target_locale_data["enabled"],
},
)
project.target_locales.exclude(locale_id__in=seen_target_locale_ids).delete()
logger.info("Synced project %s", project)
return project
class ProjectTargetLocale(models.Model):
project = models.ForeignKey(
Project,
on_delete=models.CASCADE,
related_name="target_locales",
)
locale_id = models.CharField(max_length=16)
description = models.CharField(max_length=255)
enabled = models.BooleanField()
class Meta:
unique_together = ["project", "locale_id"]
def __str__(self):
return f"{self.description}"
@register_translation_component(
required=smartling_settings.REQUIRED,
heading=_("Mark translation for Smartling processing"),
enable_text=_("Click to mark for Smartling processing"),
disable_text=_("Click to skip Smartling processing"),
)
class Job(SyncedModel):
"""
Represents a job in Smartling and its links to TranslationSource and
Translation objects in Wagtail.
"""
# wagtail-localize fields
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="+",
)
translation_source = models.ForeignKey(
TranslationSource,
on_delete=models.CASCADE,
related_name="smartling_jobs",
)
# TODO record status of imported translations per `Translation`
translations = models.ManyToManyField(
Translation,
related_name="smartling_jobs",
)
content_hash = models.CharField(max_length=64, blank=True)
# Smartling job config fields
name = models.CharField(max_length=170, editable=False)
description = models.TextField(blank=True, editable=False)
reference_number = models.CharField(max_length=170, editable=False)
due_date = models.DateTimeField(blank=True, null=True)
# Smartling API-derived fields
translation_job_uid = models.CharField(max_length=64, editable=False)
status = models.CharField(
max_length=32,
choices=JobStatus.choices,
default=JobStatus.UNSYNCED,
editable=False,
)
# Our fields
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="jobs")
translations_imported_at = models.DateTimeField(null=True, editable=False)
# NB - `file_uri`` isn't a field that the Smartling API returns. The
# intended way to get this information is from the sourceFiles value in the
# job details data or via the dedicated endpoint that lists file within a
# job. However, we only ever have one file per job, so we store the URI of
# that file here for convenience once it's been added.
#
# Refs:
# https://api-reference.smartling.com/#tag/Jobs/operation/getJobDetails
# https://api-reference.smartling.com/#tag/Jobs/operation/getJobFilesList
#
file_uri = models.CharField(max_length=255, blank=True, editable=False)
base_form_class = JobForm
panels = [FieldPanel("due_date")]
class Meta(SyncedModel.Meta):
default_permissions = ("view",)
constraints = [
models.CheckConstraint(
check=(
models.Q(
status=JobStatus.UNSYNCED,
first_synced_at__isnull=True,
last_synced_at__isnull=True,
translation_job_uid="",
)
| (
~models.Q(status=JobStatus.UNSYNCED)
& models.Q(
first_synced_at__isnull=False,
last_synced_at__isnull=False,
)
& ~models.Q(translation_job_uid="")
)
),
name="status_consistent_with_sync_dates",
),
]
def __str__(self):
return self.name
@staticmethod
def get_default_name(
translation_source: TranslationSource,
translations: Iterable[Translation],
) -> str:
"""
Default name to use for the Job we want to create in Smartling. It needs
to be unique else Smartling will reject the Job when we try to make it.
Our template is`$OPTIONAL_PREFIX $UNIQUE_HASH $SOURCE_ID`
* `$OPTIONAL_PREFIX` will be set via the `JOB_NAME_PREFIX` setting
* `$UNIQUE_HASH` is an 8-character hash - not too long to be cumbersone,
not too short to risk collisions. We hash the current timestamp plus
relevant locale codes, then use the first 8 chars. (Collision risk
is 1 in 4.2bn and we'll never generate that many jobs!)
* `$SOURCE_ID` is the integer PK of the source object being translated.
This is mainly because it gives us a simple, incrementing number that
translators can use as a quick reference, but it can also be traced
back to something in the CMS if we need to. Note that this ID alone
will not be unique across all Jobs, because it will be re-used if a
source object were to be amended and resubmitted for translation.
"""
_timestamp = (
timezone.now()
.replace(tzinfo=None) # remove +00:00 - we know it's UTC
.isoformat(timespec="seconds")
)
_locales = ":".join(sorted(t.target_locale.language_code for t in translations))
hash = hashlib.md5(
_timestamp.encode("ascii") + _locales.encode("ascii"),
usedforsecurity=False,
).hexdigest()[:8]
name = f"{hash} #{translation_source.pk}"
if smartling_settings.JOB_NAME_PREFIX:
name = f"{smartling_settings.JOB_NAME_PREFIX} {name}"
return name
@staticmethod
def get_default_reference_number(
translation_source: TranslationSource,
translations: Iterable[Translation],
) -> str:
"""
Default reference number to use for the job in Smartling. This is just
the translation_key for the source object. Setting this lets us easily
look up all the Smartling jobs associated with a particular source
object.
"""
return f"{translation_source.object.translation_key}"
@staticmethod
def get_description(
translation_source: TranslationSource,
translations: Iterable[Translation],
) -> str:
"""
Default description to use for the job in Smartling. This is
human-readable and contains a link to the edit view for the translatable
model.
If the JOB_DESCRIPTION_CALLBACK setting is set to a function or importable
string, it will be called with the default description, the translation source
and the target translations. It is expected to return a string.
"""
source_instance = translation_source.get_source_instance()
ct_name = type(source_instance)._meta.verbose_name
description = f"CMS translation job for {ct_name} '{source_instance}'."
if callback_fn := smartling_settings.JOB_DESCRIPTION_CALLBACK:
description = callback_fn(description, source_instance, translations)
return description
@classmethod
def get_or_create_from_source_and_translation_data(
cls,
translation_source: TranslationSource,
translations: Iterable[Translation],
*,
user: AbstractBaseUser,
due_date: datetime | None,
) -> None:
"""
This is the main entrypoint for creating Jobs. Jobs created here are in
a pending state until the `sync_smartling` management command picks them
up and creates a corresponding job in Smartling via the API.
Jobs are only created if there's no pending or completed job for the provided
TranslationSource.
"""
# TODO only submit locales that match Smartling target locales
# TODO make sure the source locale matches the Smartling project's language
# TODO lookup existing jobs
# TODO make sure existing job lookup only refers to current project
project = Project.get_current()
content_hash = compute_content_hash(translation_source.export_po())
# Check whether we have any pending jobs for the same translation source content
if Job.objects.filter(
project=project,
translation_source=translation_source,
content_hash=content_hash,
status__in=UNSYNCED_OR_PENDING_STATUSES,
).exists():
return
job = Job.objects.create(
project=project,
translation_source=translation_source,
user=user,
name=cls.get_default_name(translation_source, translations),
description=cls.get_description(translation_source, translations),
reference_number=cls.get_default_reference_number(
translation_source, translations
),
due_date=due_date,
content_hash=content_hash,
)
job.translations.set(translations)
if isinstance(background, ImmediateBackend):
# Don't enqueue anything slow if we're using the dummy background
# worker, let the `sync_smartling` management command pick things up
# on a schedule instead.
return
# If we get here we've got a proper background worker, so we can safely
# enqueue the syncing of the job.
background.enqueue(sync_job, args=(job.pk,), kwargs={})
class LandedTranslationTaskManager(models.Manager):
def incomplete(self):
return self.filter(
completed_on__isnull=True,
cancelled_on__isnull=True,
)
def create_from_source_and_translation(
self,
source_object: models.Model,
translated_locale: Locale,
) -> "LandedTranslationTask":
"""
Make a LandedTranslationTask for all users of the translation-approval
group, for the relevant translation of the source object.
Note that the source object is the instance that was translated (from
the Job), not the resulting translation. This is why we need to look the
latter up via the relevant locale.
We do store the resulting translated object (e.g. Page or Snippet) as
the target of the generic FK.
"""
translated_object = source_object.get_translations().get( # pyright: ignore[reportAttributeAccessIssue]
locale=translated_locale
)
c_type = ContentType.objects.get_for_model(translated_object)
task, created = LandedTranslationTask.objects.get_or_create(
content_type=c_type,
object_id=translated_object.pk,
relevant_locale=translated_locale,
completed_on__isnull=True,
cancelled_on__isnull=True,
)
action = "made" if created else "found"
msg = (
f"Translation-approval task {action} for {c_type.name}#{translated_object.pk}"
f" in {translated_locale.language_name}."
)
logger.info(msg)
return task
class LandedTranslationTask(models.Model):
"""
A custom task prompting members of a particular Group to review and
publish a particular Page or Snippet, which has just had translations land.
Note that this is _not_ a subclass of Task, and we don't want it to sit
within a workflow because Workflows are applied at a root or branching point,
whereas we want these to be applied specifically for certain pages only, and
not be auto-added to any potential child pages via a Workflow's cascade.
"""
content_type = models.ForeignKey(
ContentType,
verbose_name=_("content type"),
related_name="wagtail_localize_smartling_tasks",
on_delete=models.CASCADE,
)
object_id = models.PositiveIntegerField()
# content_object points to the translated item of content that this
# task is for:
content_object = GenericForeignKey("content_type", "object_id")
relevant_locale = models.ForeignKey(
# Denormed locale field to make ORM lookups simpler
Locale,
null=False,
on_delete=models.CASCADE,
)
created_on = models.DateTimeField(auto_now_add=True)
completed_on = models.DateTimeField(null=True, blank=True)
cancelled_on = models.DateTimeField(null=True, blank=True)
objects = LandedTranslationTaskManager()
def __str__(self):
return f"LandedTranslationTask for {self.content_object} (#{self.object_id}) in {self.relevant_locale.language_name}" # noqa: E501
def __repr__(self):
return f"<LandedTranslationTask: {self.content_type.name}#{self.object_id}>"
def edit_url_for_translated_item(self):
if isinstance(self.content_object, Page):
edit_url = reverse("wagtailadmin_pages:edit", args=[self.object_id])
else:
edit_url = get_snippet_admin_url(self.content_object)
return edit_url
def complete(self):
self.completed_on = timezone.now()
self.cancelled_on = None
self.save(update_fields=["completed_on", "cancelled_on"])
logger.info(
f"LandedTranslationTask{self.pk} completed"
) # TODO: add Wagtail log so we know who did this
def cancel(self):
self.completed_on = None
self.cancelled_on = timezone.now()
self.save(update_fields=["completed_on", "cancelled_on"])
logger.info(f"LandedTranslationTask{self.pk} cancelled")
# TODO: add Wagtail log so we know who did this
def is_completed(self) -> bool:
return bool(self.completed_on)
def is_cancelled(self) -> bool:
return bool(self.cancelled_on)