From 5db109ce2757176fe3b7c1b06056673776d9d262 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sat, 17 Oct 2026 19:03:58 +0300 Subject: [PATCH] feat(program): persist image framing and optional mobile cover --- docs/program-image-framing.md | 39 +++++ .../0020_partnerprogram_image_framing.py | 22 +++ partner_programs/models.py | 10 ++ partner_programs/serializers/management.py | 37 ++++ partner_programs/serializers/programs.py | 5 + .../tests/test_image_framing_api.py | 160 ++++++++++++++++++ 6 files changed, 273 insertions(+) create mode 100644 docs/program-image-framing.md create mode 100644 partner_programs/migrations/0020_partnerprogram_image_framing.py create mode 100644 partner_programs/tests/test_image_framing_api.py diff --git a/docs/program-image-framing.md b/docs/program-image-framing.md new file mode 100644 index 00000000..14b8d4c2 --- /dev/null +++ b/docs/program-image-framing.md @@ -0,0 +1,39 @@ +# Program image framing + +Organizer settings add optional `mobile_cover_image_address` and `image_framing`. +Existing object-scoped manager/staff permissions, atomic lock and +`expected_updated_at` conflict detection remain in effect. + +`PATCH /programs/:id/management/settings/basic/` accepts: + +```json +{ + "expected_updated_at": "2026-10-17T12:00:00Z", + "mobile_cover_image_address": "https://example.com/mobile.png", + "image_framing": { + "cover": { "x": 50, "y": 50, "zoom": 1 }, + "mobile_cover": { "x": 50, "y": 50, "zoom": 1 }, + "logo": { "x": 50, "y": 50, "zoom": 1 } + } +} +``` + +The document is replaced as a whole. All three frames and exactly x/y/zoom are +required when nonempty. x/y are finite numbers in0–100; zoom is finite in1–3. +Booleans, numeric strings, null, unknown/missing keys and non-finite values are400. +`{}` restores centered, unscaled legacy framing. Omitted fields preserve values. +The mobile URL may be empty/null: the client falls back to the main cover, retaining +an independently editable mobile frame. Original image URLs are not rewritten. + +Management GET and both public/member detail serializers expose both fields. +Program list exposes `image_framing` so logo cropping stays consistent in cards. +These fields are additive and optional for existing clients; legacy rows default +to `{}` and null. No new permission, endpoint or storage transport is introduced. + +Migration0020 adds the two fields. Deploy backend/migration before clients that +write them. Rolling back application code can retain the additive DB columns. + +Verification: five framing tests plus management/detail/program regressions on +isolated PostgreSQL; roundtrip/list/member/public, legacy partial writes, reset, +strict invalid documents, finite/bounded numbers, staff/manager and negative roles, +stale409 and absence of partial mutation. Full CI and rollout evidence belong to PR. diff --git a/partner_programs/migrations/0020_partnerprogram_image_framing.py b/partner_programs/migrations/0020_partnerprogram_image_framing.py new file mode 100644 index 00000000..4dd917f7 --- /dev/null +++ b/partner_programs/migrations/0020_partnerprogram_image_framing.py @@ -0,0 +1,22 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("partner_programs", "0019_legacy_project_team_policy")] + + operations = [ + migrations.AddField( + model_name="partnerprogram", + name="mobile_cover_image_address", + field=models.URLField( + blank=True, null=True, verbose_name="Ссылка на мобильную обложку" + ), + ), + migrations.AddField( + model_name="partnerprogram", + name="image_framing", + field=models.JSONField( + blank=True, default=dict, verbose_name="Кадрирование изображений программы" + ), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 008d4fb1..fd3ce000 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -76,6 +76,16 @@ class PartnerProgram(models.Model): blank=True, verbose_name="Ссылка на обложку", ) + mobile_cover_image_address = models.URLField( + null=True, + blank=True, + verbose_name="Ссылка на мобильную обложку", + ) + image_framing = models.JSONField( + default=dict, + blank=True, + verbose_name="Кадрирование изображений программы", + ) advertisement_image_address = models.URLField( null=True, blank=True, diff --git a/partner_programs/serializers/management.py b/partner_programs/serializers/management.py index 20569b13..02b24407 100644 --- a/partner_programs/serializers/management.py +++ b/partner_programs/serializers/management.py @@ -1,5 +1,6 @@ """Контракты панели организатора; поля ролей и жизненного цикла не редактируются.""" +import math from urllib.parse import urlsplit from rest_framework import serializers @@ -26,6 +27,39 @@ def to_internal_value(self, data): return value +class ImageFramingField(serializers.JSONField): + """Full framing document; empty object restores legacy center/100% defaults.""" + + def to_internal_value(self, data): + data = super().to_internal_value(data) + if not isinstance(data, dict): + raise serializers.ValidationError("Укажите объект настроек кадра.") + if not data: + return {} + if set(data) != {"cover", "mobile_cover", "logo"}: + raise serializers.ValidationError( + "Укажите только cover, mobile_cover и logo." + ) + for frame in data.values(): + if not isinstance(frame, dict) or set(frame) != {"x", "y", "zoom"}: + raise serializers.ValidationError( + "Каждый кадр должен содержать x, y, zoom." + ) + for key, maximum in (("x", 100), ("y", 100), ("zoom", 3)): + value = frame[key] + minimum = 1 if key == "zoom" else 0 + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not minimum <= value <= maximum + or not math.isfinite(value) + ): + raise serializers.ValidationError( + f"{key}: укажите число от {minimum} до {maximum}." + ) + return data + + class ManagementListQuery(StrictFieldsMixin, serializers.Serializer): search = serializers.CharField( required=False, allow_blank=True, max_length=200, default="" @@ -63,6 +97,8 @@ def validate(self, attrs): "description", "image_address", "cover_image_address", + "mobile_cover_image_address", + "image_framing", "advertisement_image_address", "registration_link", "presentation_address", @@ -77,6 +113,7 @@ def validate(self, attrs): class ManagementSettingsSerializer(StrictFieldsMixin, serializers.ModelSerializer): + image_framing = ImageFramingField(required=False) expected_updated_at = serializers.DateTimeField(write_only=True) confirm_reopen = serializers.BooleanField( write_only=True, required=False, default=False diff --git a/partner_programs/serializers/programs.py b/partner_programs/serializers/programs.py index a91dda5b..a5842646 100644 --- a/partner_programs/serializers/programs.py +++ b/partner_programs/serializers/programs.py @@ -70,6 +70,7 @@ class Meta: "id", "name", "image_address", + "image_framing", "short_description", "registration_link", "datetime_registration_ends", @@ -179,6 +180,8 @@ class Meta: "materials", "image_address", "cover_image_address", + "mobile_cover_image_address", + "image_framing", "presentation_address", "registration_link", "views_count", @@ -207,6 +210,8 @@ class Meta: "materials", "image_address", "cover_image_address", + "mobile_cover_image_address", + "image_framing", "advertisement_image_address", "presentation_address", "registration_link", diff --git a/partner_programs/tests/test_image_framing_api.py b/partner_programs/tests/test_image_framing_api.py new file mode 100644 index 00000000..a62774eb --- /dev/null +++ b/partner_programs/tests/test_image_framing_api.py @@ -0,0 +1,160 @@ +from copy import deepcopy + +from django.test import TestCase +from django.urls import reverse +from rest_framework.exceptions import ValidationError +from rest_framework.test import APIClient + +from partner_programs.serializers.management import ImageFramingField +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) +from project_rates.tests.helpers import create_rate_expert + + +def framing(): + return { + "cover": {"x": 25, "y": 70, "zoom": 1.8}, + "mobile_cover": {"x": 85, "y": 30, "zoom": 2.5}, + "logo": {"x": 0, "y": 100, "zoom": 3}, + } + + +class ProgramImageFramingTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.program = create_partner_program() + cls.manager = create_user(prefix="framing-manager") + cls.program.managers.add(cls.manager) + cls.member = create_program_member(cls.program).user + cls.other = create_user(prefix="framing-other-manager") + create_partner_program().managers.add(cls.other) + cls.expert = create_rate_expert(program=cls.program) + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(self.manager) + self.url = reverse( + "partner_programs:management-settings", + kwargs={"pk": self.program.pk, "section": "basic"}, + ) + + def patch(self, **values): + self.program.refresh_from_db() + return self.client.patch( + self.url, + {"expected_updated_at": self.program.datetime_updated.isoformat(), **values}, + format="json", + ) + + def test_save_roundtrip_public_member_list_and_legacy_partial_write(self): + result = self.patch( + mobile_cover_image_address="https://example.com/mobile.png", + image_framing=framing(), + ) + self.assertEqual(result.status_code, 200, result.data) + self.assertEqual(result.data["image_framing"], framing()) + self.assertEqual( + result.data["mobile_cover_image_address"], "https://example.com/mobile.png" + ) + for user in (None, self.manager, self.member): + with self.subTest(user=user): + self.client.force_authenticate(user) + response = self.client.get(f"/programs/{self.program.pk}/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["image_framing"], framing()) + self.assertEqual( + response.data["mobile_cover_image_address"], + "https://example.com/mobile.png", + ) + self.client.force_authenticate(self.manager) + response = self.client.get("/programs/") + row = next( + row for row in response.data["results"] if row["id"] == self.program.pk + ) + self.assertEqual(row["image_framing"], framing()) + self.assertEqual(self.patch(name="Legacy client rename").status_code, 200) + self.program.refresh_from_db() + self.assertEqual(self.program.image_framing, framing()) + + def test_legacy_defaults_and_explicit_reset(self): + self.program.refresh_from_db() + self.assertEqual(self.program.image_framing, {}) + self.assertIsNone(self.program.mobile_cover_image_address) + self.assertEqual(self.patch(image_framing=framing()).status_code, 200) + result = self.patch(image_framing={}, mobile_cover_image_address=None) + self.assertEqual(result.status_code, 200) + self.assertEqual(result.data["image_framing"], {}) + self.assertIsNone(result.data["mobile_cover_image_address"]) + + def test_invalid_document_rejected_without_partial_name_or_url_update(self): + bad_frames = [ + None, + [], + "bad", + {"cover": {}}, + {**framing(), "extra": {}}, + {**framing(), "logo": None}, + ] + for key, value in ( + ("x", -1), + ("y", 101), + ("zoom", 0.9), + ("zoom", 3.1), + ("x", True), + ("x", 10**400), + ("x", "50"), + ("y", None), + ): + invalid = deepcopy(framing()) + invalid["logo"][key] = value + bad_frames.append(invalid) + original = self.program.name + for data in bad_frames: + with self.subTest(data=data): + response = self.patch(name="Must not persist", image_framing=data) + self.assertEqual(response.status_code, 400) + self.program.refresh_from_db() + self.assertEqual(self.program.name, original) + self.assertEqual(self.program.image_framing, {}) + self.assertEqual( + self.patch(mobile_cover_image_address="javascript:alert(1)").status_code, 400 + ) + + def test_non_finite_values_rejected_at_field_boundary(self): + for value in (float("nan"), float("inf"), -float("inf")): + data = framing() + data["cover"]["x"] = value + with self.assertRaises(ValidationError): + ImageFramingField().run_validation(data) + + def test_write_permissions_and_stale_version_cover_new_fields(self): + for user, status in ( + (None, 401), + (self.member, 403), + (self.expert, 403), + (self.other, 403), + ): + self.client.force_authenticate(user) + self.assertEqual(self.patch(image_framing=framing()).status_code, status) + self.client.force_authenticate(create_user(is_staff=True)) + self.assertEqual(self.patch(image_framing=framing()).status_code, 200) + self.client.force_authenticate(self.manager) + self.program.refresh_from_db() + old_version = self.program.datetime_updated.isoformat() + self.assertEqual(self.patch(name="New version").status_code, 200) + result = self.client.patch( + self.url, + { + "expected_updated_at": old_version, + "image_framing": {}, + "mobile_cover_image_address": "https://example.com/stale.png", + }, + format="json", + ) + self.assertEqual(result.status_code, 409) + self.program.refresh_from_db() + self.assertEqual(self.program.image_framing, framing()) + self.assertIsNone(self.program.mobile_cover_image_address)