generated from westfarn/web_django_template
Align the public site with the PRINTFORGE banner for client demos.
CI / test (pull_request) Successful in 38s
CI / test (pull_request) Successful in 38s
Replace orange with cyan/lime/navy, swap the splash for a print animation, make smart-crop optional, and add a prod-safe seed_demo command. Closes #5. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+11
-5
@@ -418,7 +418,7 @@ def normalize_hex(value: str) -> str:
|
||||
return raw.lower()
|
||||
|
||||
|
||||
def store_product_image(*, upload, user) -> StoredFile:
|
||||
def store_product_image(*, upload, user, smart_crop: bool = True) -> StoredFile:
|
||||
content_type = (getattr(upload, "content_type", None) or "").lower()
|
||||
if content_type not in _ALLOWED_IMAGE_TYPES:
|
||||
raise ShopError("Use a JPEG, PNG, GIF, or WebP image.")
|
||||
@@ -427,15 +427,18 @@ def store_product_image(*, upload, user) -> StoredFile:
|
||||
raise ShopError("Image must be 15 MB or smaller.")
|
||||
original = (getattr(upload, "name", None) or "product")[:255]
|
||||
try:
|
||||
from shop.imaging import prepare_product_photo, product_image_filename
|
||||
from shop.imaging import open_image, prepare_product_photo, product_image_filename
|
||||
except ImportError as exc:
|
||||
logger.exception("product photo processing dependencies missing")
|
||||
raise ShopError(
|
||||
"Image processing is not installed. Rebuild the app container."
|
||||
) from exc
|
||||
try:
|
||||
data, content_type = prepare_product_photo(data)
|
||||
original = product_image_filename(original)
|
||||
if smart_crop:
|
||||
data, content_type = prepare_product_photo(data)
|
||||
original = product_image_filename(original)
|
||||
else:
|
||||
open_image(data)
|
||||
except ShopError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
@@ -569,12 +572,15 @@ def append_product_images(
|
||||
uploads,
|
||||
user,
|
||||
color: ProductColor | None = None,
|
||||
smart_crop: bool = True,
|
||||
) -> None:
|
||||
existing = product.images.filter(color=color).count()
|
||||
for offset, upload in enumerate(uploads):
|
||||
if not upload:
|
||||
continue
|
||||
stored = store_product_image(upload=upload, user=user)
|
||||
stored = store_product_image(
|
||||
upload=upload, user=user, smart_crop=smart_crop
|
||||
)
|
||||
ProductImage.objects.create(
|
||||
product=product,
|
||||
color=color,
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<div class="stl-viewer"
|
||||
data-stl-viewer
|
||||
data-src="{{ product.stl_url }}"
|
||||
data-color="{{ selected_color.hex|default:'#ff6252' }}"
|
||||
data-color="{{ selected_color.hex|default:'#00aeef' }}"
|
||||
data-label="3D model of {{ product.name }}. Drag to rotate, scroll to zoom."></div>
|
||||
<p class="product-media-hint">Drag to spin · scroll to zoom</p>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,12 @@
|
||||
<div class="field">
|
||||
<label>Photos</label>
|
||||
<input name="images" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple>
|
||||
<p class="hint">JPEG, PNG, GIF, or WebP. Multiple photos. Used on the listing card and as the default gallery. Color-specific photos below replace these when that color is selected.</p>
|
||||
<input type="hidden" name="smart_crop" value="off">
|
||||
<label class="inline">
|
||||
<input type="checkbox" name="smart_crop" value="on" checked>
|
||||
Smart crop and background filter
|
||||
</label>
|
||||
<p class="hint">JPEG, PNG, GIF, or WebP. Multiple photos. Used on the listing card and as the default gallery. Color-specific photos below replace these when that color is selected. Leave the filter on to knock out the background and center the subject on a square canvas.</p>
|
||||
{% if product.catalog_images %}
|
||||
<div class="photo-thumbs">
|
||||
{% for image in product.catalog_images %}
|
||||
@@ -114,7 +119,7 @@
|
||||
<div id="product-stl-preview"
|
||||
class="stl-viewer stl-viewer-compact"
|
||||
data-stl-viewer
|
||||
data-color="#ff6252"
|
||||
data-color="#00aeef"
|
||||
data-label="STL preview. Drag to rotate, scroll to zoom."
|
||||
{% if product.stl_id %}data-src="{{ product.stl_url }}" data-existing="{{ product.stl_url }}"{% else %}hidden{% endif %}></div>
|
||||
</aside>
|
||||
|
||||
@@ -414,6 +414,8 @@ class ShopPortalTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Shop card preview")
|
||||
self.assertContains(response, 'name="images"')
|
||||
self.assertContains(response, 'name="smart_crop"')
|
||||
self.assertContains(response, "Smart crop and background filter")
|
||||
self.assertContains(response, 'name="stl"')
|
||||
self.assertContains(response, "Add color")
|
||||
|
||||
@@ -512,6 +514,32 @@ class ShopPortalTests(TestCase):
|
||||
b"".join(fetch.streaming_content), bytes(product.image.data)
|
||||
)
|
||||
|
||||
def test_create_product_skips_smart_crop_when_unchecked(self):
|
||||
raw = _tiny_png()
|
||||
upload = SimpleUploadedFile("keep.png", raw, content_type="image/png")
|
||||
with patch("shop.imaging.cutout_subject") as cut:
|
||||
response = self.client.post(
|
||||
reverse("shop_portal:product_new"),
|
||||
{
|
||||
"name": "Raw photo toy",
|
||||
"sku": "RAW-1",
|
||||
"price": "10.00",
|
||||
"stock_qty": "1",
|
||||
"fulfillment": "stocked",
|
||||
"track_inventory": "on",
|
||||
"smart_crop": "off",
|
||||
"images": upload,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
cut.assert_not_called()
|
||||
product = Product.objects.get(sku="RAW-1")
|
||||
stored = product.images.get().file
|
||||
self.assertEqual(bytes(stored.data), raw)
|
||||
self.assertEqual(stored.filename, "keep.png")
|
||||
framed = Image.open(BytesIO(bytes(stored.data)))
|
||||
self.assertEqual(framed.size, (8, 8))
|
||||
|
||||
def test_create_product_with_color_photos_and_shared_stl(self):
|
||||
catalog = SimpleUploadedFile("card.png", _tiny_png(), content_type="image/png")
|
||||
red_photo = SimpleUploadedFile("red.png", _tiny_png(), content_type="image/png")
|
||||
@@ -582,6 +610,20 @@ class ShopPortalTests(TestCase):
|
||||
self.assertTrue(bytes(stored.data))
|
||||
self.assertEqual(stored.kind, StoredFile.Kind.PRODUCT_IMAGE)
|
||||
|
||||
def test_store_product_image_can_skip_smart_crop(self):
|
||||
raw = _tiny_png()
|
||||
with TemporaryUploadedFile("dot.png", "image/png", 0, "utf-8") as tmp:
|
||||
tmp.write(raw)
|
||||
tmp.seek(0)
|
||||
with patch("shop.imaging.cutout_subject") as cut:
|
||||
stored = store_product_image(
|
||||
upload=tmp, user=self.user, smart_crop=False
|
||||
)
|
||||
cut.assert_not_called()
|
||||
self.assertEqual(bytes(stored.data), raw)
|
||||
self.assertEqual(stored.content_type, "image/png")
|
||||
self.assertEqual(stored.filename, "dot.png")
|
||||
|
||||
def test_temporary_stl_upload_copied_into_database_then_unlinked(self):
|
||||
with TemporaryUploadedFile(
|
||||
"toy.stl", "application/octet-stream", 0, "utf-8"
|
||||
|
||||
@@ -298,10 +298,12 @@ def portal_product_edit(request, pk=None):
|
||||
remove_product_images(
|
||||
product, request.POST.getlist("remove_image")
|
||||
)
|
||||
smart_crop = request.POST.get("smart_crop", "on") == "on"
|
||||
append_product_images(
|
||||
product,
|
||||
uploads=request.FILES.getlist("images"),
|
||||
user=request.user,
|
||||
smart_crop=smart_crop,
|
||||
)
|
||||
for key, color in colors.items():
|
||||
uploads = request.FILES.getlist(f"color_images_{key}")
|
||||
@@ -312,6 +314,7 @@ def portal_product_edit(request, pk=None):
|
||||
uploads=uploads,
|
||||
user=request.user,
|
||||
color=color,
|
||||
smart_crop=smart_crop,
|
||||
)
|
||||
refresh_listing_image(product)
|
||||
_delete_replaced_file(
|
||||
|
||||
Reference in New Issue
Block a user