Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 39 additions & 0 deletions docs/program-image-framing.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions partner_programs/migrations/0020_partnerprogram_image_framing.py
Original file line number Diff line number Diff line change
@@ -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="Кадрирование изображений программы"
),
),
]
10 changes: 10 additions & 0 deletions partner_programs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions partner_programs/serializers/management.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Контракты панели организатора; поля ролей и жизненного цикла не редактируются."""

import math
from urllib.parse import urlsplit

from rest_framework import serializers
Expand All @@ -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=""
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions partner_programs/serializers/programs.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ class Meta:
"id",
"name",
"image_address",
"image_framing",
"short_description",
"registration_link",
"datetime_registration_ends",
Expand Down Expand Up @@ -179,6 +180,8 @@ class Meta:
"materials",
"image_address",
"cover_image_address",
"mobile_cover_image_address",
"image_framing",
"presentation_address",
"registration_link",
"views_count",
Expand Down Expand Up @@ -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",
Expand Down
160 changes: 160 additions & 0 deletions partner_programs/tests/test_image_framing_api.py
Original file line number Diff line number Diff line change
@@ -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)
Loading