Protect published snapshots and concurrent edits

This commit is contained in:
2026-09-27 10:20:38 +03:30
parent b8e79c950a
commit f529f85bf0
3 changed files with 59 additions and 1 deletions

View File

@@ -31,6 +31,9 @@ class MediaAsset(models.Model):
approved = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.file.name or f"Media asset {self.pk}"
def clean(self):
super().clean()
for field in ("alt", "source", "license"):
@@ -60,6 +63,11 @@ class MediaAsset(models.Model):
self.bytes = size
def save(self, *args, **kwargs):
if self.pk:
previous = type(self).objects.get(pk=self.pk)
protected = ("file", "alt", "source", "license", "width", "height", "bytes")
if previous.approved and (not self.approved or any(getattr(self, field) != getattr(previous, field) for field in protected)):
raise ValidationError("Approved media is immutable.")
self.full_clean()
return super().save(*args, **kwargs)
@@ -77,6 +85,9 @@ class SiteIdentity(models.Model):
("restore_content", "Can restore published content"),
]
def __str__(self):
return "Association site identity"
def clean(self):
if self.published_revision_id and self.published_revision.site_id != self.pk:
raise ValidationError("Published revision belongs to another site.")
@@ -126,6 +137,8 @@ class SiteRevision(models.Model):
protected = ("site_id", "number", "official_name", "short_name", "faculty_subline", "description", "logo_id", "logo_alt", "navigation", "contacts", "footer_copy", "seo_title", "seo_description", "seo_image_id", "created_by_id", "source_seed_key", "source_seed_version", "content_checksum")
if any(getattr(self, field) != getattr(previous, field) for field in protected):
raise ValidationError("Site revision content is immutable.")
if previous.status == "published" and (self.status != previous.status or self.published_by_id != previous.published_by_id or self.published_at != previous.published_at):
raise ValidationError("Published site revision metadata is immutable.")
return super().save(*args, **kwargs)
@@ -134,6 +147,9 @@ class EditorialPage(models.Model):
page_kind = models.CharField(max_length=20, choices=[("sections", "Sections"), ("listing_intro", "Listing intro")])
published_revision = models.ForeignKey("PageRevision", null=True, blank=True, on_delete=models.PROTECT, related_name="published_for")
def __str__(self):
return self.slug
def clean(self):
super().clean()
if self.slug not in PAGE_KINDS or self.page_kind != PAGE_KINDS[self.slug]:
@@ -177,6 +193,8 @@ class PageRevision(models.Model):
raise ValidationError("Page revision content is immutable.")
if self.content_checksum != previous.content_checksum and previous.content_checksum:
raise ValidationError("Page revision checksum is immutable.")
if previous.status == "published" and (self.status != previous.status or self.published_by_id != previous.published_by_id or self.published_at != previous.published_at):
raise ValidationError("Published page revision metadata is immutable.")
return super().save(*args, **kwargs)
@@ -218,7 +236,8 @@ class PageSection(models.Model):
if self.key == "contact" and not self.visible:
raise ValidationError("About contact must be visible.")
validators.text(self.heading, 120 if self.key in {"hero", "identity"} else 90, required=True)
validators.text(self.body, 1200 if slug == "about" else 600)
body_limit = 500 if self.key == "hero" else 600 if self.key in {"participation", "identity"} else 1200
validators.text(self.body, body_limit)
validators.text(self.intro, 300)
validators.text(self.all_link_label, 40)
validators.text(self.image_alt, 180)