adloop 0.24.0__tar.gz → 0.26.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {adloop-0.24.0 → adloop-0.26.0}/PKG-INFO +5 -3
- {adloop-0.24.0 → adloop-0.26.0}/README.md +4 -2
- {adloop-0.24.0 → adloop-0.26.0}/pyproject.toml +1 -1
- {adloop-0.24.0 → adloop-0.26.0}/pyproject.toml.orig +1 -1
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/conversion_actions.py +295 -16
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/write.py +38 -7
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/reddit/read.py +123 -1
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/reddit/write.py +446 -18
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/rules/adloop.md +29 -4
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/server.py +284 -27
- adloop-0.26.0/src/adloop/upload_card.html +247 -0
- adloop-0.26.0/src/adloop/uploads.py +286 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/__init__.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/__main__.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/brands.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/image_fetch.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/auth.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/cli.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/config.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/crossref.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/gtm/entities.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/gtm/write.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/merchant/client.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/merchant/read.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/runtime.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/safety/approval.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/safety/approval_card.html +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.24.0 → adloop-0.26.0}/src/adloop/tracking.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: adloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.26.0
|
|
4
4
|
Summary: The AI command center for Google Ads, Reddit Ads, GA4, and tracking code.
|
|
5
5
|
Keywords: mcp,google-ads,reddit-ads,google-analytics,ga4,cursor,marketing
|
|
6
6
|
Author: Daniel Klose
|
|
@@ -251,12 +251,14 @@ A second ad platform, same safety model. Reddit is a separate connection: its ow
|
|
|
251
251
|
| `get_reddit_performance` | Spend, clicks, CTR, CPC, conversions, CPA, ROAS per account/campaign/ad group/ad, optional breakdown (date, country, community, keyword, placement, …), compact mode with insights |
|
|
252
252
|
| `run_reddit_report` | Raw reports endpoint for any metric (REACH, video, per-event conversions) |
|
|
253
253
|
| `get_reddit_pixels` | Pixels and when each event last fired — flags ad groups optimizing for events the pixel never sent |
|
|
254
|
+
| `get_reddit_audiences` | Custom and saved audiences with status, size and source, plus the ad groups that target or exclude each |
|
|
254
255
|
| `search_reddit_targeting` | Communities, interests, geolocations, languages, keyword suggestions, and Reddit's related-community suggestions (by seed communities or website) |
|
|
255
256
|
| `get_reddit_account_history` | Who changed what and when: field, before/after, member |
|
|
256
257
|
| `estimate_reddit_ad_group` | Audience size, delivery estimate and suggested bid range for a planned ad group (Reddit's counterpart of `estimate_budget`) |
|
|
257
258
|
| `pause_reddit_entity` / `enable_reddit_entity` / `remove_reddit_entity` | Status changes through the preview gate (remove = ARCHIVE, irreversible, double-confirmed) |
|
|
258
|
-
| `update_reddit_campaign` / `update_reddit_ad_group` / `update_reddit_ad` | Budget, bid, run dates, weekly schedule (day names, viewer-local hours), targeting and
|
|
259
|
-
| `draft_reddit_campaign` / `draft_reddit_ad_group` / `draft_reddit_ad` | Create campaign → ad group (pixel + targeting required) → post + ad, or promote an existing post with `post_id`. Everything is created **PAUSED**. |
|
|
259
|
+
| `update_reddit_campaign` / `update_reddit_ad_group` / `update_reddit_ad` | Budget, bid, run dates, weekly schedule (day names, viewer-local hours), targeting, placements and audiences to target or exclude (validated with Reddit at draft time), landing URL and comment changes with old → new previews, budget cap and bid-increase guards |
|
|
260
|
+
| `draft_reddit_campaign` / `draft_reddit_ad_group` / `draft_reddit_ad` | Create campaign → ad group (pixel + targeting required) → post + ad (text, image, video or a 2–6 card carousel, media by URL), or promote an existing post with `post_id`. Everything is created **PAUSED**. |
|
|
261
|
+
| `draft_reddit_custom_audience` | Retargeting audiences from website visitors (pixel events) or people who engaged with your campaigns. No customer-list uploads. |
|
|
260
262
|
|
|
261
263
|
> **Setup for Reddit Ads tools** — In Reddit Ads Manager open **Business Manager → Developer Application → Create app** (business admins only; no approval wait). Register the redirect URL exactly as `http://localhost:8765/callback`, then run `adloop init` and complete the Reddit Ads step: it opens Reddit's consent page (scopes `adsread` + `adsedit`, permanent grant), stores the refresh token at `~/.adloop/reddit_token.json`, and lets you pick the default ad account. Reddit rate-limits per user (reporting: 60 requests/min) and requires a descriptive User-Agent, which AdLoop builds from your app id and Reddit username. Reddit has no validate-only mode, so `confirm_and_apply(dry_run=true)` re-reads the target and re-checks the safety caps instead.
|
|
262
264
|
|
|
@@ -223,12 +223,14 @@ A second ad platform, same safety model. Reddit is a separate connection: its ow
|
|
|
223
223
|
| `get_reddit_performance` | Spend, clicks, CTR, CPC, conversions, CPA, ROAS per account/campaign/ad group/ad, optional breakdown (date, country, community, keyword, placement, …), compact mode with insights |
|
|
224
224
|
| `run_reddit_report` | Raw reports endpoint for any metric (REACH, video, per-event conversions) |
|
|
225
225
|
| `get_reddit_pixels` | Pixels and when each event last fired — flags ad groups optimizing for events the pixel never sent |
|
|
226
|
+
| `get_reddit_audiences` | Custom and saved audiences with status, size and source, plus the ad groups that target or exclude each |
|
|
226
227
|
| `search_reddit_targeting` | Communities, interests, geolocations, languages, keyword suggestions, and Reddit's related-community suggestions (by seed communities or website) |
|
|
227
228
|
| `get_reddit_account_history` | Who changed what and when: field, before/after, member |
|
|
228
229
|
| `estimate_reddit_ad_group` | Audience size, delivery estimate and suggested bid range for a planned ad group (Reddit's counterpart of `estimate_budget`) |
|
|
229
230
|
| `pause_reddit_entity` / `enable_reddit_entity` / `remove_reddit_entity` | Status changes through the preview gate (remove = ARCHIVE, irreversible, double-confirmed) |
|
|
230
|
-
| `update_reddit_campaign` / `update_reddit_ad_group` / `update_reddit_ad` | Budget, bid, run dates, weekly schedule (day names, viewer-local hours), targeting and
|
|
231
|
-
| `draft_reddit_campaign` / `draft_reddit_ad_group` / `draft_reddit_ad` | Create campaign → ad group (pixel + targeting required) → post + ad, or promote an existing post with `post_id`. Everything is created **PAUSED**. |
|
|
231
|
+
| `update_reddit_campaign` / `update_reddit_ad_group` / `update_reddit_ad` | Budget, bid, run dates, weekly schedule (day names, viewer-local hours), targeting, placements and audiences to target or exclude (validated with Reddit at draft time), landing URL and comment changes with old → new previews, budget cap and bid-increase guards |
|
|
232
|
+
| `draft_reddit_campaign` / `draft_reddit_ad_group` / `draft_reddit_ad` | Create campaign → ad group (pixel + targeting required) → post + ad (text, image, video or a 2–6 card carousel, media by URL), or promote an existing post with `post_id`. Everything is created **PAUSED**. |
|
|
233
|
+
| `draft_reddit_custom_audience` | Retargeting audiences from website visitors (pixel events) or people who engaged with your campaigns. No customer-list uploads. |
|
|
232
234
|
|
|
233
235
|
> **Setup for Reddit Ads tools** — In Reddit Ads Manager open **Business Manager → Developer Application → Create app** (business admins only; no approval wait). Register the redirect URL exactly as `http://localhost:8765/callback`, then run `adloop init` and complete the Reddit Ads step: it opens Reddit's consent page (scopes `adsread` + `adsedit`, permanent grant), stores the refresh token at `~/.adloop/reddit_token.json`, and lets you pick the default ad account. Reddit rate-limits per user (reporting: 60 requests/min) and requires a descriptive User-Agent, which AdLoop builds from your app id and Reddit username. Reddit has no validate-only mode, so `confirm_and_apply(dry_run=true)` re-reads the target and re-checks the safety caps instead.
|
|
234
236
|
|
|
@@ -26,7 +26,7 @@ import math
|
|
|
26
26
|
import re
|
|
27
27
|
import threading
|
|
28
28
|
from datetime import datetime, timezone as _tz
|
|
29
|
-
from typing import TYPE_CHECKING
|
|
29
|
+
from typing import TYPE_CHECKING, Callable
|
|
30
30
|
|
|
31
31
|
from adloop.ads.enums import enum_names
|
|
32
32
|
|
|
@@ -685,13 +685,21 @@ def _gaql_escape(s: str) -> str:
|
|
|
685
685
|
return s.replace("\\", "\\\\").replace("'", "\\'")
|
|
686
686
|
|
|
687
687
|
|
|
688
|
+
# The consent values a caller may send. Google's ConsentStatus enum also has
|
|
689
|
+
# UNKNOWN, but that one reports a state the API determined, not one a caller
|
|
690
|
+
# declares, so it is not accepted here. Shared with the per-row columns so the
|
|
691
|
+
# two cannot drift apart.
|
|
692
|
+
_CONSENT_VALUES = frozenset({"GRANTED", "DENIED", "UNSPECIFIED"})
|
|
693
|
+
|
|
694
|
+
|
|
688
695
|
def _consent_from_param(consent: dict | None) -> dict | None:
|
|
689
696
|
"""Validate + normalize the ``consent`` tool parameter.
|
|
690
697
|
|
|
691
698
|
Accepts ``{"ad_user_data": "GRANTED"|"DENIED"|"UNSPECIFIED",
|
|
692
|
-
"ad_personalization": ...}``.
|
|
693
|
-
|
|
694
|
-
|
|
699
|
+
"ad_personalization": ...}``. Missing keys default to UNSPECIFIED. Returns
|
|
700
|
+
a plain dict stored in the plan (JSON-safe, non-PII), or None if no consent
|
|
701
|
+
was supplied at all. ``UNKNOWN`` is refused: the enum has it, but it
|
|
702
|
+
reports what Google determined, not something a caller declares.
|
|
695
703
|
|
|
696
704
|
An unknown key is an error rather than a silent no-op: ``adPersonalization``
|
|
697
705
|
(camelCase) would otherwise leave the field at UNSPECIFIED, which for EEA
|
|
@@ -711,19 +719,117 @@ def _consent_from_param(consent: dict | None) -> dict | None:
|
|
|
711
719
|
f"consent has unknown key(s): {', '.join(unknown)}. Use only "
|
|
712
720
|
"ad_user_data and ad_personalization."
|
|
713
721
|
)
|
|
714
|
-
valid = {"UNSPECIFIED", "UNKNOWN", "GRANTED", "DENIED"}
|
|
715
722
|
out: dict[str, str] = {}
|
|
716
723
|
for key in known:
|
|
717
724
|
raw = str(consent.get(key, "UNSPECIFIED") or "UNSPECIFIED").upper()
|
|
718
|
-
if raw not in
|
|
725
|
+
if raw not in _CONSENT_VALUES:
|
|
719
726
|
raise ValueError(
|
|
720
727
|
f"consent.{key}='{raw}' is invalid. Use one of: "
|
|
721
|
-
"GRANTED, DENIED, UNSPECIFIED
|
|
728
|
+
"GRANTED, DENIED, UNSPECIFIED."
|
|
722
729
|
)
|
|
723
730
|
out[key] = raw
|
|
724
731
|
return out
|
|
725
732
|
|
|
726
733
|
|
|
734
|
+
# Google's own template carries consent per person, not per file: Consent is a
|
|
735
|
+
# field on each Conversion, so a CRM export that mixes people who opted in with
|
|
736
|
+
# people who did not can say so row by row. The spelling is Google's.
|
|
737
|
+
_CONSENT_HEADERS = {
|
|
738
|
+
"Ad User Data": "ad_user_data",
|
|
739
|
+
"Ad Personalization": "ad_personalization",
|
|
740
|
+
}
|
|
741
|
+
def _row_consent(
|
|
742
|
+
raw: list[str], optional_col: dict[str, int]
|
|
743
|
+
) -> tuple[dict[str, str] | None, str]:
|
|
744
|
+
"""The consent one CSV row specifies: ``(consent, problem)``.
|
|
745
|
+
|
|
746
|
+
The dict holds only the fields that row actually filled in, so the
|
|
747
|
+
file-wide ``consent`` argument stays in charge of the rest. An unusable
|
|
748
|
+
value is reported without echoing the cell — errors never carry file
|
|
749
|
+
content.
|
|
750
|
+
"""
|
|
751
|
+
out: dict[str, str] = {}
|
|
752
|
+
for header, key in _CONSENT_HEADERS.items():
|
|
753
|
+
index = optional_col.get(header)
|
|
754
|
+
if index is None:
|
|
755
|
+
continue
|
|
756
|
+
value = (raw[index] or "").strip().upper()
|
|
757
|
+
if not value:
|
|
758
|
+
continue
|
|
759
|
+
if value not in _CONSENT_VALUES:
|
|
760
|
+
return None, (
|
|
761
|
+
f"{header} is not one of GRANTED, DENIED or UNSPECIFIED"
|
|
762
|
+
)
|
|
763
|
+
out[key] = value
|
|
764
|
+
return (out or None), ""
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _effective_consent(
|
|
768
|
+
row_consent: dict | None, default: dict | None
|
|
769
|
+
) -> dict | None:
|
|
770
|
+
"""What one row uploads with: its own cells over the file-wide default.
|
|
771
|
+
|
|
772
|
+
Filling the remaining field with UNSPECIFIED keeps each stored row readable
|
|
773
|
+
on its own; ``_apply_consent`` would default it that way anyway.
|
|
774
|
+
"""
|
|
775
|
+
if not row_consent and not default:
|
|
776
|
+
return None
|
|
777
|
+
merged = dict(default or {})
|
|
778
|
+
merged.update(row_consent or {})
|
|
779
|
+
for key in ("ad_user_data", "ad_personalization"):
|
|
780
|
+
merged.setdefault(key, "UNSPECIFIED")
|
|
781
|
+
return merged
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _consent_summary(
|
|
785
|
+
rows: list[dict], default: dict | None
|
|
786
|
+
) -> dict[str, dict[str, int]]:
|
|
787
|
+
"""How many uploaded rows carry each consent value, per field.
|
|
788
|
+
|
|
789
|
+
The preview is where an upload gets approved, and the plan-level ``consent``
|
|
790
|
+
is only the fallback for rows without their own: a file whose rows say
|
|
791
|
+
DENIED must not preview as GRANTED because that is the argument.
|
|
792
|
+
"""
|
|
793
|
+
summary: dict[str, dict[str, int]] = {
|
|
794
|
+
"ad_user_data": {}, "ad_personalization": {},
|
|
795
|
+
}
|
|
796
|
+
for row in rows:
|
|
797
|
+
values = _effective_consent(row.get("consent"), default) or {}
|
|
798
|
+
for key in summary:
|
|
799
|
+
value = values.get(key, "UNSPECIFIED")
|
|
800
|
+
summary[key][value] = summary[key].get(value, 0) + 1
|
|
801
|
+
return summary
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _consent_warnings(
|
|
805
|
+
rows: list[dict], *, columns_present: bool, default: dict | None
|
|
806
|
+
) -> list[str]:
|
|
807
|
+
"""Rows that end up UNSPECIFIED even though the file tried to carry consent.
|
|
808
|
+
|
|
809
|
+
Google uses UNSPECIFIED conversions only in a limited way for EEA traffic,
|
|
810
|
+
and empty consent cells still look like the file says something. A file
|
|
811
|
+
without consent columns stays quiet: that is today's behaviour, and a
|
|
812
|
+
warning on every such file would only train callers to ignore it.
|
|
813
|
+
"""
|
|
814
|
+
if not columns_present:
|
|
815
|
+
return []
|
|
816
|
+
unspecified = 0
|
|
817
|
+
for row in rows:
|
|
818
|
+
values = _effective_consent(row.get("consent"), default) or {}
|
|
819
|
+
if any(
|
|
820
|
+
values.get(key, "UNSPECIFIED") == "UNSPECIFIED"
|
|
821
|
+
for key in ("ad_user_data", "ad_personalization")
|
|
822
|
+
):
|
|
823
|
+
unspecified += 1
|
|
824
|
+
if not unspecified:
|
|
825
|
+
return []
|
|
826
|
+
return [
|
|
827
|
+
f"{unspecified} of {len(rows)} row(s) upload as UNSPECIFIED for "
|
|
828
|
+
"consent: their consent cells are empty or only partly filled. Google "
|
|
829
|
+
"uses UNSPECIFIED conversions only in a limited way for EEA traffic."
|
|
830
|
+
]
|
|
831
|
+
|
|
832
|
+
|
|
727
833
|
def _apply_consent(client: object, conversion: object, consent: dict | None) -> None:
|
|
728
834
|
"""Set conversion.consent.{ad_user_data,ad_personalization} from a plan dict.
|
|
729
835
|
|
|
@@ -1032,6 +1138,24 @@ def _short_row(raw: list[str], col: dict[str, int]) -> bool:
|
|
|
1032
1138
|
return len(raw) <= max(col.values())
|
|
1033
1139
|
|
|
1034
1140
|
|
|
1141
|
+
def _optional_column_indexes(
|
|
1142
|
+
header: list[str], names: list[str]
|
|
1143
|
+
) -> dict[str, int]:
|
|
1144
|
+
"""Indexes of optional columns, matched case-insensitively.
|
|
1145
|
+
|
|
1146
|
+
The required columns come from Google's template verbatim. Optional ones
|
|
1147
|
+
are typed by hand into exports often enough that "Order ID" versus
|
|
1148
|
+
"order id" should not decide whether the file has a dedup key.
|
|
1149
|
+
"""
|
|
1150
|
+
folded = [cell.casefold() for cell in header]
|
|
1151
|
+
found: dict[str, int] = {}
|
|
1152
|
+
for name in names:
|
|
1153
|
+
key = name.casefold()
|
|
1154
|
+
if key in folded:
|
|
1155
|
+
found[name] = folded.index(key)
|
|
1156
|
+
return found
|
|
1157
|
+
|
|
1158
|
+
|
|
1035
1159
|
def _pad_to_header(raw: list[str], columns: int) -> list[str]:
|
|
1036
1160
|
"""Fill missing trailing cells with "" so every header index exists."""
|
|
1037
1161
|
if len(raw) >= columns:
|
|
@@ -1067,6 +1191,9 @@ def _parse_call_conversion_csv(
|
|
|
1067
1191
|
col, errors = _column_map(header, _EXPECTED_CALL_HEADERS)
|
|
1068
1192
|
if errors:
|
|
1069
1193
|
return [], errors, [], []
|
|
1194
|
+
optional_col = _optional_column_indexes(
|
|
1195
|
+
[cell.strip() for cell in header], list(_CONSENT_HEADERS)
|
|
1196
|
+
)
|
|
1070
1197
|
|
|
1071
1198
|
now = datetime.now(_tz.utc)
|
|
1072
1199
|
out: list[dict] = []
|
|
@@ -1083,6 +1210,10 @@ def _parse_call_conversion_csv(
|
|
|
1083
1210
|
})
|
|
1084
1211
|
continue
|
|
1085
1212
|
raw = _pad_to_header(raw, len(header))
|
|
1213
|
+
row_consent, problem = _row_consent(raw, optional_col)
|
|
1214
|
+
if problem:
|
|
1215
|
+
skipped.append({"row": source_line, "reason": problem})
|
|
1216
|
+
continue
|
|
1086
1217
|
value, currency, problem = _parse_amount(
|
|
1087
1218
|
raw[col["Conversion Value"]], raw[col["Conversion Currency"]]
|
|
1088
1219
|
)
|
|
@@ -1137,6 +1268,8 @@ def _parse_call_conversion_csv(
|
|
|
1137
1268
|
"conversion_time": converted,
|
|
1138
1269
|
"conversion_value": value,
|
|
1139
1270
|
"currency_code": currency,
|
|
1271
|
+
"consent": row_consent,
|
|
1272
|
+
"consent_columns_present": bool(optional_col),
|
|
1140
1273
|
})
|
|
1141
1274
|
return out, errors, advisories, skipped
|
|
1142
1275
|
|
|
@@ -1203,7 +1336,9 @@ def draft_upload_call_conversions(
|
|
|
1203
1336
|
``consent`` (GDPR/EEA): a dict like
|
|
1204
1337
|
``{"ad_user_data": "GRANTED", "ad_personalization": "DENIED"}``. Values:
|
|
1205
1338
|
GRANTED / DENIED / UNSPECIFIED. Required for EEA traffic. Defaults to
|
|
1206
|
-
UNSPECIFIED when omitted.
|
|
1339
|
+
UNSPECIFIED when omitted. Optional ``Ad User Data`` /
|
|
1340
|
+
``Ad Personalization`` CSV columns say it per row and override this value
|
|
1341
|
+
for the row that fills them in.
|
|
1207
1342
|
|
|
1208
1343
|
PII note: the caller phone number is required raw by Google for matching,
|
|
1209
1344
|
so the rows live in the plan's ``apply_only_payload`` — apply needs them,
|
|
@@ -1257,6 +1392,12 @@ def draft_upload_call_conversions(
|
|
|
1257
1392
|
}
|
|
1258
1393
|
rows = usable
|
|
1259
1394
|
|
|
1395
|
+
consent_warnings = _consent_warnings(
|
|
1396
|
+
rows,
|
|
1397
|
+
columns_present=any(r.get("consent_columns_present") for r in rows),
|
|
1398
|
+
default=consent_norm,
|
|
1399
|
+
)
|
|
1400
|
+
dedup_warnings = _duplicate_row_warnings(rows, _call_duplicate_key, "call")
|
|
1260
1401
|
distinct_actions = sorted({r["conversion_name"] for r in rows})
|
|
1261
1402
|
total_value, totals_by_currency, currency_hint, value_warnings = (
|
|
1262
1403
|
_value_summary(rows)
|
|
@@ -1293,6 +1434,7 @@ def draft_upload_call_conversions(
|
|
|
1293
1434
|
"conversion_time": r["conversion_time"],
|
|
1294
1435
|
"conversion_value": r["conversion_value"],
|
|
1295
1436
|
"currency_code": r["currency_code"],
|
|
1437
|
+
"consent": _effective_consent(r.get("consent"), consent_norm),
|
|
1296
1438
|
}
|
|
1297
1439
|
for r in rows
|
|
1298
1440
|
]
|
|
@@ -1329,7 +1471,11 @@ def draft_upload_call_conversions(
|
|
|
1329
1471
|
# Resolved at draft time; apply reads them instead of querying again.
|
|
1330
1472
|
"conversion_actions": action_resources,
|
|
1331
1473
|
"consent": consent_norm,
|
|
1332
|
-
|
|
1474
|
+
# What the rows actually upload with; ``consent`` alone is only the
|
|
1475
|
+
# fallback and would misrepresent a file that sets it per row.
|
|
1476
|
+
"consent_summary": _consent_summary(rows, consent_norm),
|
|
1477
|
+
"parse_warnings": [*parse_advisories, *consent_warnings],
|
|
1478
|
+
"dedup_warnings": dedup_warnings,
|
|
1333
1479
|
# Display sample uses REDACTED caller ids only.
|
|
1334
1480
|
"sample_rows": [
|
|
1335
1481
|
{
|
|
@@ -1337,6 +1483,7 @@ def draft_upload_call_conversions(
|
|
|
1337
1483
|
"call_start_time": r["call_start_time"],
|
|
1338
1484
|
"conversion_name": r["conversion_name"],
|
|
1339
1485
|
"conversion_value": r["conversion_value"],
|
|
1486
|
+
"consent": _effective_consent(r.get("consent"), consent_norm),
|
|
1340
1487
|
}
|
|
1341
1488
|
for r in rows[:3]
|
|
1342
1489
|
],
|
|
@@ -1461,6 +1608,106 @@ def _value_summary(
|
|
|
1461
1608
|
return total, by_currency, hint, warnings
|
|
1462
1609
|
|
|
1463
1610
|
|
|
1611
|
+
def _join_source_lines(lines: list[object]) -> str:
|
|
1612
|
+
"""``4``, ``4 and 9``, ``4, 9 and 12`` — in reading order."""
|
|
1613
|
+
|
|
1614
|
+
def order(line: object) -> tuple:
|
|
1615
|
+
# Line numbers are ints; a hand-built plan may carry none. Sorting the
|
|
1616
|
+
# mixed list by ``str`` alone would put line 10 in front of line 9.
|
|
1617
|
+
return (0, line, "") if isinstance(line, int) else (1, 0, str(line))
|
|
1618
|
+
|
|
1619
|
+
parts = [str(line) for line in sorted(lines, key=order)]
|
|
1620
|
+
if len(parts) == 1:
|
|
1621
|
+
return parts[0]
|
|
1622
|
+
return f"{', '.join(parts[:-1])} and {parts[-1]}"
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def _duplicate_row_warnings(
|
|
1626
|
+
rows: list[dict],
|
|
1627
|
+
key_of: Callable[[dict], tuple | None],
|
|
1628
|
+
what: str,
|
|
1629
|
+
order_id_of: Callable[[dict], str] | None = None,
|
|
1630
|
+
) -> list[str]:
|
|
1631
|
+
"""Rows that repeat inside one CSV — reported, not refused.
|
|
1632
|
+
|
|
1633
|
+
Google dedups a re-upload on the Order ID, and a call upload has no dedup
|
|
1634
|
+
key at all, so the same record twice in one file goes out twice and nothing
|
|
1635
|
+
in the result says so. Legitimate repetition exists (two calls from the
|
|
1636
|
+
same number, the same second), so the rows are named and the caller decides
|
|
1637
|
+
in the preview.
|
|
1638
|
+
|
|
1639
|
+
``order_id_of`` splits the wording: rows that share an Order ID collapse
|
|
1640
|
+
into one conversion at Google, the others add up.
|
|
1641
|
+
"""
|
|
1642
|
+
seen: dict[tuple, list[tuple[object, dict]]] = {}
|
|
1643
|
+
for index, row in enumerate(rows, start=1):
|
|
1644
|
+
key = key_of(row)
|
|
1645
|
+
if key:
|
|
1646
|
+
seen.setdefault(key, []).append((_source_line(row, index), row))
|
|
1647
|
+
warnings: list[str] = []
|
|
1648
|
+
for group in seen.values():
|
|
1649
|
+
if len(group) < 2:
|
|
1650
|
+
continue
|
|
1651
|
+
lines = [line for line, _ in group]
|
|
1652
|
+
order_id = order_id_of(group[0][1]) if order_id_of else ""
|
|
1653
|
+
if order_id:
|
|
1654
|
+
warnings.append(
|
|
1655
|
+
f"{len(group)} rows look like the same {what} and carry the "
|
|
1656
|
+
f"same Order ID: lines {_join_source_lines(lines)}. Google "
|
|
1657
|
+
"dedups on the conversion action and that Order ID, so they "
|
|
1658
|
+
"count once."
|
|
1659
|
+
)
|
|
1660
|
+
continue
|
|
1661
|
+
warnings.append(
|
|
1662
|
+
f"{len(group)} rows look like the same {what}: lines "
|
|
1663
|
+
f"{_join_source_lines(lines)}. Every row is uploaded as its own "
|
|
1664
|
+
f"conversion, so the record is counted {len(group)} times — delete "
|
|
1665
|
+
"the extra rows if it is one copy-paste accident."
|
|
1666
|
+
)
|
|
1667
|
+
return warnings
|
|
1668
|
+
|
|
1669
|
+
|
|
1670
|
+
def _call_duplicate_key(row: dict) -> tuple | None:
|
|
1671
|
+
"""What identifies a call: the number and the start of the call.
|
|
1672
|
+
|
|
1673
|
+
The value stays out of the key on purpose: two rows for the same call with
|
|
1674
|
+
different values are still almost certainly one call exported twice.
|
|
1675
|
+
"""
|
|
1676
|
+
caller = row.get("caller_id") or ""
|
|
1677
|
+
if not caller:
|
|
1678
|
+
return None
|
|
1679
|
+
return (caller, row.get("call_start_time") or "")
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
def _ec_duplicate_key(row: dict) -> tuple | None:
|
|
1683
|
+
"""What identifies a lead: the strongest identifier the row carries.
|
|
1684
|
+
|
|
1685
|
+
A row that only matched on its address is compared by that address, not
|
|
1686
|
+
reported as a duplicate of every other row without an email. The Order ID
|
|
1687
|
+
is part of the key whenever the row has one: two orders for the same lead
|
|
1688
|
+
at the same (often date-only) time are two real orders, not one.
|
|
1689
|
+
"""
|
|
1690
|
+
identifier: object = row.get("email_sha256") or row.get("phone_sha256")
|
|
1691
|
+
if not identifier and _has_complete_address(row):
|
|
1692
|
+
identifier = (
|
|
1693
|
+
row["first_name_sha256"],
|
|
1694
|
+
row["last_name_sha256"],
|
|
1695
|
+
row["country_code"],
|
|
1696
|
+
row["postal_code"],
|
|
1697
|
+
)
|
|
1698
|
+
if not identifier:
|
|
1699
|
+
return None
|
|
1700
|
+
return (
|
|
1701
|
+
identifier,
|
|
1702
|
+
row.get("conversion_time") or "",
|
|
1703
|
+
row.get("order_id") or "",
|
|
1704
|
+
)
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
def _row_order_id(row: dict) -> str:
|
|
1708
|
+
return str(row.get("order_id") or "")
|
|
1709
|
+
|
|
1710
|
+
|
|
1464
1711
|
def _upload_draft_preflight(
|
|
1465
1712
|
config: AdLoopConfig,
|
|
1466
1713
|
*,
|
|
@@ -1954,7 +2201,10 @@ def _apply_upload_call_conversions(
|
|
|
1954
2201
|
cc.conversion_value = float(r["conversion_value"])
|
|
1955
2202
|
if r.get("currency_code"):
|
|
1956
2203
|
cc.currency_code = r["currency_code"]
|
|
1957
|
-
|
|
2204
|
+
# The row's own value wins; the file-wide argument is the fallback
|
|
2205
|
+
# for rows that left the consent columns empty (and for plans
|
|
2206
|
+
# drafted before the columns existed).
|
|
2207
|
+
_apply_consent(client, cc, r.get("consent") or consent)
|
|
1958
2208
|
payload.append(cc)
|
|
1959
2209
|
return payload
|
|
1960
2210
|
|
|
@@ -2020,8 +2270,10 @@ def _parse_ec_for_leads_csv(
|
|
|
2020
2270
|
Conversion Name, Conversion Time, Conversion Value, Conversion Currency.
|
|
2021
2271
|
Optional: Order ID (Google's dedup key — strongly recommended so
|
|
2022
2272
|
re-uploads of the same source row don't double-count), Country Code,
|
|
2023
|
-
Postal Code
|
|
2024
|
-
|
|
2273
|
+
Postal Code, and Google's per-person consent columns ``Ad User Data`` /
|
|
2274
|
+
``Ad Personalization``. Returns ``(rows, errors, advisories, skipped)``:
|
|
2275
|
+
``errors`` are file-level problems, ``advisories`` describe rows that still
|
|
2276
|
+
upload, and
|
|
2025
2277
|
``skipped`` lists every row that will not be uploaded with its
|
|
2026
2278
|
``source_line``. A record that ends before the last required column is
|
|
2027
2279
|
skipped instead of aborting the draft.
|
|
@@ -2041,7 +2293,9 @@ def _parse_ec_for_leads_csv(
|
|
|
2041
2293
|
if errors:
|
|
2042
2294
|
return [], errors, [], []
|
|
2043
2295
|
header = [cell.strip() for cell in raw_header]
|
|
2044
|
-
optional_col =
|
|
2296
|
+
optional_col = _optional_column_indexes(
|
|
2297
|
+
header, [*_OPTIONAL_EC_HEADERS, *_CONSENT_HEADERS]
|
|
2298
|
+
)
|
|
2045
2299
|
|
|
2046
2300
|
out: list[dict] = []
|
|
2047
2301
|
skipped: list[dict] = []
|
|
@@ -2060,6 +2314,10 @@ def _parse_ec_for_leads_csv(
|
|
|
2060
2314
|
# the header promises exists (Country Code and Postal Code used to
|
|
2061
2315
|
# raise IndexError here).
|
|
2062
2316
|
raw = _pad_to_header(raw, len(header))
|
|
2317
|
+
row_consent, problem = _row_consent(raw, optional_col)
|
|
2318
|
+
if problem:
|
|
2319
|
+
skipped.append({"row": source_line, "reason": problem})
|
|
2320
|
+
continue
|
|
2063
2321
|
value, currency, problem = _parse_amount(
|
|
2064
2322
|
raw[col["Conversion Value"]], raw[col["Conversion Currency"]]
|
|
2065
2323
|
)
|
|
@@ -2136,6 +2394,10 @@ def _parse_ec_for_leads_csv(
|
|
|
2136
2394
|
"conversion_value": value,
|
|
2137
2395
|
"currency_code": currency,
|
|
2138
2396
|
"order_id": order_id,
|
|
2397
|
+
"consent": row_consent,
|
|
2398
|
+
"consent_columns_present": any(
|
|
2399
|
+
header in optional_col for header in _CONSENT_HEADERS
|
|
2400
|
+
),
|
|
2139
2401
|
})
|
|
2140
2402
|
return out, errors, advisories, skipped
|
|
2141
2403
|
|
|
@@ -2226,7 +2488,9 @@ def draft_upload_enhanced_conversions_for_leads(
|
|
|
2226
2488
|
``consent`` (GDPR/EEA): a dict like
|
|
2227
2489
|
``{"ad_user_data": "GRANTED", "ad_personalization": "DENIED"}``. Values:
|
|
2228
2490
|
GRANTED / DENIED / UNSPECIFIED. Required for EEA traffic. Defaults to
|
|
2229
|
-
UNSPECIFIED when omitted.
|
|
2491
|
+
UNSPECIFIED when omitted. Optional ``Ad User Data`` /
|
|
2492
|
+
``Ad Personalization`` CSV columns say it per row and override this value
|
|
2493
|
+
for the row that fills them in.
|
|
2230
2494
|
|
|
2231
2495
|
Call confirm_and_apply with the returned plan_id to execute.
|
|
2232
2496
|
"""
|
|
@@ -2304,6 +2568,11 @@ def draft_upload_enhanced_conversions_for_leads(
|
|
|
2304
2568
|
}
|
|
2305
2569
|
rows = usable
|
|
2306
2570
|
|
|
2571
|
+
consent_warnings = _consent_warnings(
|
|
2572
|
+
rows,
|
|
2573
|
+
columns_present=any(r.get("consent_columns_present") for r in rows),
|
|
2574
|
+
default=consent_norm,
|
|
2575
|
+
)
|
|
2307
2576
|
distinct_actions = sorted({r["conversion_name"] for r in rows})
|
|
2308
2577
|
total_value, totals_by_currency, currency_hint, value_warnings = (
|
|
2309
2578
|
_value_summary(rows)
|
|
@@ -2369,6 +2638,11 @@ def draft_upload_enhanced_conversions_for_leads(
|
|
|
2369
2638
|
f"Only {with_order_id} of {len(rows)} rows have an Order ID. "
|
|
2370
2639
|
"Rows without one will double-count on re-upload."
|
|
2371
2640
|
)
|
|
2641
|
+
dedup_warnings.extend(
|
|
2642
|
+
_duplicate_row_warnings(
|
|
2643
|
+
rows, _ec_duplicate_key, "lead", order_id_of=_row_order_id
|
|
2644
|
+
)
|
|
2645
|
+
)
|
|
2372
2646
|
|
|
2373
2647
|
# Freeze the hashed rows apply will upload. These are already SHA-256
|
|
2374
2648
|
# hashes — NO raw PII. Safe to persist in the plan and (order_id/value/
|
|
@@ -2387,6 +2661,7 @@ def draft_upload_enhanced_conversions_for_leads(
|
|
|
2387
2661
|
"conversion_value": r["conversion_value"],
|
|
2388
2662
|
"currency_code": r["currency_code"],
|
|
2389
2663
|
"order_id": r.get("order_id", ""),
|
|
2664
|
+
"consent": _effective_consent(r.get("consent"), consent_norm),
|
|
2390
2665
|
}
|
|
2391
2666
|
for r in rows
|
|
2392
2667
|
]
|
|
@@ -2425,7 +2700,10 @@ def draft_upload_enhanced_conversions_for_leads(
|
|
|
2425
2700
|
# Resolved at draft time; apply reads them instead of querying again.
|
|
2426
2701
|
"conversion_actions": action_resources,
|
|
2427
2702
|
"consent": consent_norm,
|
|
2428
|
-
|
|
2703
|
+
# What the rows actually upload with; ``consent`` alone is only the
|
|
2704
|
+
# fallback and would misrepresent a file that sets it per row.
|
|
2705
|
+
"consent_summary": _consent_summary(rows, consent_norm),
|
|
2706
|
+
"parse_warnings": [*parse_advisories, *consent_warnings],
|
|
2429
2707
|
"dedup_warnings": dedup_warnings,
|
|
2430
2708
|
"match_warnings": _match_warnings(
|
|
2431
2709
|
names_without_address,
|
|
@@ -2447,6 +2725,7 @@ def draft_upload_enhanced_conversions_for_leads(
|
|
|
2447
2725
|
"conversion_value": r["conversion_value"],
|
|
2448
2726
|
"conversion_time": r["conversion_time"],
|
|
2449
2727
|
"order_id": r.get("order_id", ""),
|
|
2728
|
+
"consent": _effective_consent(r.get("consent"), consent_norm),
|
|
2450
2729
|
}
|
|
2451
2730
|
for r in rows[:3]
|
|
2452
2731
|
],
|
|
@@ -2484,7 +2763,7 @@ def _apply_upload_enhanced_conversions_for_leads(
|
|
|
2484
2763
|
cc.currency_code = r["currency_code"]
|
|
2485
2764
|
if r.get("order_id"):
|
|
2486
2765
|
cc.order_id = r["order_id"]
|
|
2487
|
-
_apply_consent(client, cc, consent)
|
|
2766
|
+
_apply_consent(client, cc, r.get("consent") or consent)
|
|
2488
2767
|
|
|
2489
2768
|
# Hashed identifiers only; Google matches them to logged-in users
|
|
2490
2769
|
# who clicked the ads. A row with no usable identifier never gets
|
|
@@ -432,7 +432,7 @@ def _size_kb(data: bytes) -> float:
|
|
|
432
432
|
|
|
433
433
|
def _describe_image(image: dict[str, object]) -> str:
|
|
434
434
|
"""One preview line: source, type, dimensions and size."""
|
|
435
|
-
source = image.get("url") or image.get("path")
|
|
435
|
+
source = image.get("url") or image.get("path") or f"upload {image.get('upload_id')}"
|
|
436
436
|
kind = str(image.get("mime_type", "")).removeprefix("image/").upper()
|
|
437
437
|
return (
|
|
438
438
|
f"{source} — {kind} {image.get('width')}x{image.get('height')}, "
|
|
@@ -2457,12 +2457,14 @@ def draft_image_assets(
|
|
|
2457
2457
|
campaign_id: str = "",
|
|
2458
2458
|
image_paths: list[str] | None = None,
|
|
2459
2459
|
image_urls: list[str] | None = None,
|
|
2460
|
+
upload_ids: list[str] | None = None,
|
|
2460
2461
|
) -> dict:
|
|
2461
|
-
"""Draft campaign image assets from local files
|
|
2462
|
+
"""Draft campaign image assets from local files, public image URLs and/or uploads.
|
|
2462
2463
|
|
|
2463
2464
|
URL images are fetched now (public addresses only, pinned against DNS
|
|
2464
2465
|
rebinding, capped at Google's 5120 KB limit) and their sha256 is kept
|
|
2465
|
-
in the plan; apply re-fetches and refuses if the bytes changed.
|
|
2466
|
+
in the plan; apply re-fetches and refuses if the bytes changed. Uploads
|
|
2467
|
+
(``up_...`` ids from the upload card or link) are pinned the same way.
|
|
2466
2468
|
"""
|
|
2467
2469
|
from adloop.runtime import deployment_mode
|
|
2468
2470
|
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
@@ -2486,7 +2488,7 @@ def draft_image_assets(
|
|
|
2486
2488
|
return {"error": str(e)}
|
|
2487
2489
|
|
|
2488
2490
|
validated_images, errors = _validate_image_assets(
|
|
2489
|
-
campaign_id, image_paths, image_urls
|
|
2491
|
+
campaign_id, image_paths, image_urls, list(upload_ids or [])
|
|
2490
2492
|
)
|
|
2491
2493
|
if errors:
|
|
2492
2494
|
return {"error": "Validation failed", "details": errors}
|
|
@@ -3300,16 +3302,38 @@ def _validate_structured_snippets(
|
|
|
3300
3302
|
|
|
3301
3303
|
|
|
3302
3304
|
def _validate_image_assets(
|
|
3303
|
-
campaign_id: str,
|
|
3305
|
+
campaign_id: str,
|
|
3306
|
+
image_paths: list[str],
|
|
3307
|
+
image_urls: list[str] | None = None,
|
|
3308
|
+
upload_ids: list[str] | None = None,
|
|
3304
3309
|
) -> tuple[list[dict[str, object]], list[str]]:
|
|
3305
3310
|
errors = []
|
|
3306
3311
|
validated = []
|
|
3307
3312
|
image_urls = image_urls or []
|
|
3313
|
+
upload_ids = upload_ids or []
|
|
3308
3314
|
|
|
3309
3315
|
if not campaign_id:
|
|
3310
3316
|
errors.append("campaign_id is required")
|
|
3311
|
-
if not image_paths and not image_urls:
|
|
3312
|
-
errors.append("At least one image (image_paths or
|
|
3317
|
+
if not image_paths and not image_urls and not upload_ids:
|
|
3318
|
+
errors.append("At least one image (image_paths, image_urls or upload_ids) is required")
|
|
3319
|
+
|
|
3320
|
+
for index, upload_id in enumerate(upload_ids):
|
|
3321
|
+
from adloop.uploads import UploadError, current_tenant, get_upload_store
|
|
3322
|
+
|
|
3323
|
+
try:
|
|
3324
|
+
upload, _data = get_upload_store().get(current_tenant(), upload_id)
|
|
3325
|
+
except UploadError as exc:
|
|
3326
|
+
errors.append(f"Upload {index + 1}: {exc}")
|
|
3327
|
+
continue
|
|
3328
|
+
validated.append({
|
|
3329
|
+
"upload_id": upload.upload_id,
|
|
3330
|
+
"sha256": upload.sha256,
|
|
3331
|
+
"name": _build_image_asset_name(PurePosixPath(upload.name), _data),
|
|
3332
|
+
"mime_type": upload.mime_type,
|
|
3333
|
+
"width": upload.width,
|
|
3334
|
+
"height": upload.height,
|
|
3335
|
+
"size_kb": round(upload.size_bytes / 1024, 1),
|
|
3336
|
+
})
|
|
3313
3337
|
|
|
3314
3338
|
for index, image_path in enumerate(image_paths):
|
|
3315
3339
|
try:
|
|
@@ -4916,6 +4940,13 @@ def _apply_create_image_assets(client: object, cid: str, changes: dict) -> dict:
|
|
|
4916
4940
|
if payload.get("url"):
|
|
4917
4941
|
image_bytes = _fetch_approved_image_url(payload)
|
|
4918
4942
|
name_source = _url_path(str(payload["url"]))
|
|
4943
|
+
elif payload.get("upload_id"):
|
|
4944
|
+
from adloop.uploads import current_tenant, get_upload_store
|
|
4945
|
+
|
|
4946
|
+
upload, image_bytes = get_upload_store().get(current_tenant(), str(payload["upload_id"]))
|
|
4947
|
+
if upload.sha256 != payload.get("sha256"):
|
|
4948
|
+
raise ValueError(f"upload {upload.upload_id} changed since the preview; draft again.")
|
|
4949
|
+
name_source = PurePosixPath(upload.name)
|
|
4919
4950
|
elif deployment_mode() == "server":
|
|
4920
4951
|
# Drafts refuse paths on a server; never read server files even
|
|
4921
4952
|
# if a path-based plan reached apply some other way.
|