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
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,14 @@ def import_recipes_from_uploaded_qr_cards(
invalid payload, are recorded as failures; processing continues with the
remaining files. Each failure is also published as an event so the
reason is visible in the dev terminal and the events log file.

A card whose settings match a recipe already in the library does not
create anything, so the successes are also split into the recipes the
import created and the ones it completed by naming them.
"""
imported: list[models.FujifilmRecipe] = []
created: list[models.FujifilmRecipe] = []
updated: list[models.FujifilmRecipe] = []
failed: list[str] = []

for file in files:
Expand All @@ -35,8 +41,14 @@ def import_recipes_from_uploaded_qr_cards(
with tempfile.NamedTemporaryFile(suffix=".jpg", dir="/tmp/", delete=False) as tmp:
tmp.write(file.content)
tmp_path = tmp.name
recipe, _ = operations.get_or_create_recipe_from_qr_card(filepath=tmp_path)
recipe, outcome = operations.get_or_create_recipe_from_qr_card_and_backfill_name(
filepath=tmp_path
)
imported.append(recipe)
if outcome is recipe_dataclasses.RecipeImportOutcome.CREATED:
created.append(recipe)
elif outcome is recipe_dataclasses.RecipeImportOutcome.NAME_BACKFILLED:
updated.append(recipe)
except QRCodeNotFoundError:
failed.append(file.name)
events.publish_event(
Expand All @@ -58,4 +70,6 @@ def import_recipes_from_uploaded_qr_cards(
return recipe_dataclasses.ImportRecipesResult(
imported=tuple(imported),
failed=tuple(failed),
created=tuple(created),
updated=tuple(updated),
)
1 change: 1 addition & 0 deletions src/domain/images/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
RECIPE_DEDUPLICATED = "recipe.deduplicated"
RECIPE_IMAGE_CREATED = "recipe.image.created"
RECIPE_IMAGE_UPDATED = "recipe.image.updated"
RECIPE_NAME_UPDATED = "recipe.name.updated"
RECIPE_COVER_IMAGE_SET = "recipe.cover.image.set"
RECIPE_SENSORS_SET = "recipe.sensors.set"
RECIPE_CARD_CREATED = "recipe.card.created"
Expand Down
69 changes: 42 additions & 27 deletions src/domain/recipes/cards/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,9 @@ class InvalidQRRecipePayloadError(Exception):
- ``"unknown_fields"`` — payload contains keys outside the known schema.
- ``"type_mismatch"`` — a field's value has the wrong type, or a
required field is missing.
- ``"invalid_field_value"`` — a field's value has the right type but is
not a legal value (an over-long or non-ASCII name, or an unknown
sensor name).
"""

image_path: str = ""
Expand Down Expand Up @@ -379,7 +382,7 @@ def _signed_decimal_or_none(value: int | float | None) -> str | None:


def get_recipe_data_from_qr_recipe(
*, qr_recipe: card_dataclasses.QRFujifilmRecipe,
*, qr_recipe: card_dataclasses.QRFujifilmRecipe, image_path: str,
) -> image_dataclasses.FujifilmRecipeData:
"""
Translate a decoded QRFujifilmRecipe into a FujifilmRecipeData.
Expand All @@ -397,31 +400,43 @@ def get_recipe_data_from_qr_recipe(
- grain_size when grain_roughness is "Off".
- dynamic_range/highlight/shadow when DRP is active.
- color for mono sims; mono color fields for colour sims.

:raises InvalidQRRecipePayloadError: If a field's value passes the payload
type checks but is rejected by the FujifilmRecipeData validators (an
over-long or non-ASCII name, or a sensor name this deployment does not
know).
"""
return recipe_normalization.normalize_recipe_data(
image_dataclasses.FujifilmRecipeData(
name=qr_recipe.name or "",
film_simulation=qr_recipe.film_simulation,
grain_roughness=qr_recipe.grain_roughness,
d_range_priority=qr_recipe.d_range_priority,
white_balance=qr_recipe.white_balance,
white_balance_red=qr_recipe.white_balance_red,
white_balance_blue=qr_recipe.white_balance_blue,
color_chrome_effect=qr_recipe.color_chrome_effect or "Off",
color_chrome_fx_blue=qr_recipe.color_chrome_fx_blue or "Off",
sharpness=_signed_decimal_or_none(qr_recipe.sharpness) or "0",
high_iso_nr=_signed_decimal_or_none(qr_recipe.high_iso_nr) or "0",
clarity=_signed_decimal_or_none(qr_recipe.clarity) or "0",
dynamic_range=qr_recipe.dynamic_range if qr_recipe.dynamic_range is not None else "",
grain_size=qr_recipe.grain_size,
highlight=_signed_decimal_or_none(qr_recipe.highlight) or "0",
shadow=_signed_decimal_or_none(qr_recipe.shadow) or "0",
color=_signed_decimal_or_none(qr_recipe.color) or "0",
monochromatic_color_warm_cool=_signed_decimal_or_none(qr_recipe.monochromatic_color_warm_cool) or "0",
monochromatic_color_magenta_green=_signed_decimal_or_none(qr_recipe.monochromatic_color_magenta_green) or "0",
# v=1 payloads omit ``sensors``; the dataclass leaves it None there.
# v=2 payloads may include it; either way we settle on an empty
# tuple when absent so the FujifilmRecipeData validator is happy.
sensors=qr_recipe.sensors if qr_recipe.sensors is not None else (),
try:
return recipe_normalization.normalize_recipe_data(
image_dataclasses.FujifilmRecipeData(
name=qr_recipe.name or "",
film_simulation=qr_recipe.film_simulation,
grain_roughness=qr_recipe.grain_roughness,
d_range_priority=qr_recipe.d_range_priority,
white_balance=qr_recipe.white_balance,
white_balance_red=qr_recipe.white_balance_red,
white_balance_blue=qr_recipe.white_balance_blue,
color_chrome_effect=qr_recipe.color_chrome_effect or "Off",
color_chrome_fx_blue=qr_recipe.color_chrome_fx_blue or "Off",
sharpness=_signed_decimal_or_none(qr_recipe.sharpness) or "0",
high_iso_nr=_signed_decimal_or_none(qr_recipe.high_iso_nr) or "0",
clarity=_signed_decimal_or_none(qr_recipe.clarity) or "0",
dynamic_range=qr_recipe.dynamic_range if qr_recipe.dynamic_range is not None else "",
grain_size=qr_recipe.grain_size,
highlight=_signed_decimal_or_none(qr_recipe.highlight) or "0",
shadow=_signed_decimal_or_none(qr_recipe.shadow) or "0",
color=_signed_decimal_or_none(qr_recipe.color) or "0",
monochromatic_color_warm_cool=_signed_decimal_or_none(qr_recipe.monochromatic_color_warm_cool) or "0",
monochromatic_color_magenta_green=_signed_decimal_or_none(qr_recipe.monochromatic_color_magenta_green) or "0",
# v=1 payloads omit ``sensors``; the dataclass leaves it None there.
# v=2 payloads may include it; either way we settle on an empty
# tuple when absent so the FujifilmRecipeData validator is happy.
sensors=qr_recipe.sensors if qr_recipe.sensors is not None else (),
)
)
)
except ValueError:
# The FujifilmRecipeData validators raise plain ValueErrors. Translating
# them here keeps every bad-payload failure on the same exception, so a
# single unimportable card is recorded as a failed file by the caller
# instead of aborting the whole upload.
raise InvalidQRRecipePayloadError(image_path=image_path, reason="invalid_field_value")
42 changes: 41 additions & 1 deletion src/domain/recipes/dataclasses.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
from __future__ import annotations

import enum

import attrs

from src.data import models


class RecipeImportOutcome(enum.Enum):
"""
What an import did to the library for one incoming recipe.

A recipe arriving from a shared card is matched against the existing
library by its settings, so importing it does not necessarily create
anything.
"""

CREATED = "created"
NAME_BACKFILLED = "name_backfilled" # matched an existing recipe that had no name
UNCHANGED = "unchanged" # matched an existing recipe; nothing to add


@attrs.frozen
class UploadedFile:
"""
Expand All @@ -17,5 +33,29 @@ class UploadedFile:

@attrs.frozen
class ImportRecipesResult:
"""
The outcome of importing a batch of files, one entry per file.

Importing a file does not necessarily add a recipe: the recipe it carries
is matched against the library by its settings, and may already be there.
So a successfully imported file lands in ``imported``, and additionally in
``created`` if it added a recipe to the library, or in ``updated`` if it
completed a recipe that was already there by giving it a name.

A file that matched an existing recipe and had nothing to add appears only
in ``imported``. Two files carrying the same recipe both appear, so these
are counts of files, not of distinct recipes.

Only the QR-card import can tell these apart. The import that reads
recipes out of image EXIF leaves ``created`` and ``updated`` empty.

:param imported: recipes read successfully, one per file.
:param failed: filenames that could not be read.
:param created: recipes that were new to the library.
:param updated: recipes that were already in the library and got named.
"""

imported: tuple[models.FujifilmRecipe, ...]
failed: tuple[str, ...] # original filenames that could not be processed
failed: tuple[str, ...]
created: tuple[models.FujifilmRecipe, ...] = ()
updated: tuple[models.FujifilmRecipe, ...] = ()
55 changes: 50 additions & 5 deletions src/domain/recipes/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from src.domain.images import queries as image_queries
from collections.abc import Iterable

from src.domain.recipes import dataclasses as recipe_dataclasses
from src.domain.recipes import normalization as recipe_normalization
from src.domain.recipes import queries as recipe_queries
from src.domain.recipes import sensors as recipe_sensors
Expand Down Expand Up @@ -239,6 +240,9 @@ def get_or_create_recipe_from_data(
This is the single seam for ``FujifilmRecipe.get_or_create`` — shared by
every caller that has already produced a FujifilmRecipeData (from EXIF,
from a QR card, or any future source).

Callers importing a recipe shared from another library want a name that
survives the get path: they use ``get_or_create_recipe_and_backfill_name``.
"""
data = recipe_normalization.normalize_recipe_data(data)
recipe_validation.validate_recipe_data(data)
Expand Down Expand Up @@ -341,6 +345,41 @@ def get_or_create_recipe_from_data(
return recipe, created


def get_or_create_recipe_and_backfill_name(
*,
data: image_dataclasses.FujifilmRecipeData,
group_id: int | None = None,
) -> tuple[models.FujifilmRecipe, recipe_dataclasses.RecipeImportOutcome]:
"""
Get or create the recipe for *data*, naming it if it has no name.

For recipes shared from another library, where the settings already match
an existing recipe but the local copy may predate that recipe being named.

Backfill only: an existing recipe is written to under exactly one
condition, that its ``name`` is empty and *data* carries one. A name
chosen locally is never overwritten, and ``description`` is never touched
(a QR card does not carry one).

local "" + incoming "Kodachrome" -> "Kodachrome" (backfilled)
local "My CC" + incoming "Kodachrome" -> "My CC" (kept)
local "My CC" + incoming "" -> "My CC" (kept)

Returns the recipe and what the import did to the library, so a caller
importing a batch can report how many recipes it created versus updated.
"""
recipe, created = get_or_create_recipe_from_data(data=data, group_id=group_id)
if created:
return recipe, recipe_dataclasses.RecipeImportOutcome.CREATED
if data.name and not recipe.name:
# No RecipeNameValidationError handling: the name is empty (excluded
# by the guard) or it came through FujifilmRecipeData, whose validator
# enforces the same rules set_recipe_name checks.
set_recipe_name(recipe=recipe, name=data.name)
return recipe, recipe_dataclasses.RecipeImportOutcome.NAME_BACKFILLED
return recipe, recipe_dataclasses.RecipeImportOutcome.UNCHANGED


def get_or_create_recipe_from_metadata(
*, metadata: image_dataclasses.ImageExifData,
) -> tuple[models.FujifilmRecipe, bool]:
Expand Down Expand Up @@ -370,19 +409,25 @@ def get_or_create_recipe_from_filepath(
return get_or_create_recipe_from_metadata(metadata=metadata)


def get_or_create_recipe_from_qr_card(
def get_or_create_recipe_from_qr_card_and_backfill_name(
*, filepath: str,
) -> tuple[models.FujifilmRecipe, bool]:
) -> tuple[models.FujifilmRecipe, recipe_dataclasses.RecipeImportOutcome]:
"""
Decode the QR on a recipe-card image and return the matching FujifilmRecipe.

The card names the recipe it matches when that recipe has no name of its
own — see ``get_or_create_recipe_and_backfill_name``, which also explains
what the returned outcome means.

:raises QRCodeNotFoundError: If no QR code can be decoded from *filepath*.
:raises InvalidQRRecipePayloadError: If the decoded content is not a valid
QRFujifilmRecipe payload.
"""
qr_recipe = card_queries.get_qr_recipe_from_image(image_path=filepath)
recipe_data = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr_recipe)
return get_or_create_recipe_from_data(data=recipe_data)
recipe_data = card_queries.get_recipe_data_from_qr_recipe(
qr_recipe=qr_recipe, image_path=filepath,
)
return get_or_create_recipe_and_backfill_name(data=recipe_data)


@attrs.frozen
Expand Down Expand Up @@ -464,7 +509,7 @@ def set_recipe_name(*, recipe: models.FujifilmRecipe, name: str) -> None:
raise RecipeNameValidationError(name)
recipe.set_name(name=name)
events.publish_event(
event_type=events.RECIPE_IMAGE_UPDATED,
event_type=events.RECIPE_NAME_UPDATED,
name=name,
recipe_id=recipe.pk,
)
Expand Down
7 changes: 6 additions & 1 deletion src/interfaces/recipes/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -686,7 +686,12 @@ def post(self, request: http.HttpRequest) -> http.HttpResponse:
return shortcuts.render(
request,
"recipes/partials/_import_result.html",
{"imported": result.imported, "failed": result.failed},
{
"imported": result.imported,
"failed": result.failed,
"created_count": len(result.created),
"updated_count": len(result.updated),
},
)


Expand Down
7 changes: 7 additions & 0 deletions src/interfaces/templates/recipes/partials/_import_result.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
</p>
{% endif %}

{% if created_count or updated_count %}
<p class="import-result__message">
{{ created_count }} recipe{{ created_count|pluralize }} created,
{{ updated_count }} existing recipe{{ updated_count|pluralize }} updated.
</p>
{% endif %}

{% if failed %}
<p class="import-result__message import-result__message--warning">
{{ failed|length }} file{{ failed|length|pluralize }} could not be processed:
Expand Down
Loading
Loading