diff --git a/backend/docs/agent/sc-281-mockup.html b/backend/docs/agent/sc-281-mockup.html new file mode 100644 index 000000000..fb1aef79b --- /dev/null +++ b/backend/docs/agent/sc-281-mockup.html @@ -0,0 +1,830 @@ + + + + + + sc-281 · Implement recommended target changes + + + + +
+
+
OpenDLP · Selection
+ + +
+
+ +
+ +
+

sc-281 Stratified selection improvements · Epic 426

+

Implement recommended target changes from a failed selection

+

When you run the initial selection at the end of recruitment, the algorithm may tell you the targets can't be met — but that a few adjustments would fix it. The failure view tells you what to change; it gives you no way to do it. You open the Targets tab and apply the numbers by hand, juggling two tabs: one with the suggestions, one with the editor.

+

sc-281 wires the fix into the place the problem appears: an Adjust targets link on the failed run takes you straight to the targets check with the suggestions showing, where one-click Accept buttons apply them — and, since you came from a run, offer to re-run the selection.

+
+ type Feature + priority High + pattern from sc-447 replacements + entry Selection failure view +
+
+ +
+
01

Where it breaks today

+
+

The trigger. Running the initial selection fails on infeasible quotas. The run's failure view already lists the adjustments the solver suggests — as read-only prose. There's no action there, so it's a dead end.

+

The workaround. Open the Targets tab in another window, run the check, and retype each suggested minimum/maximum by hand — keeping both tabs open to copy between them.

+
+

The fix. A link from the failure view to targets/check?from=selection (check already run), where each suggestion carries an Accept button, plus Accept all and — because you arrived from a run — Accept all & re-run selection. Accepting also records an audit comment on the value.

+
+ +
+
02

Interactive prototype

+

Walk the real journey: a failed selection run → Adjust targets → accept suggestions → re-check or re-run. Two decisions are still open, so the prototype lets you flip between them — View (today vs proposed) and Comment mode (the two candidate behaviours for the audit comment). Pick the combination you want to review.

+ +
+
+
+ Stage +
+ + → + +
+
+
+ View +
+ + +
+
+
+ Comment mode +
+ + +
+
+ +
+

+
+ +
+
+ + /backoffice/assembly/ebcb87…/selection?current_selection=bef4… +
+
+
+
+ +
+
03

User flow

+

From running the selection to a feasible — or freshly running — selection, without leaving for a second tab.

+
+
STEP 1

Run initial selection → fails

Quotas are infeasible; the failure view lists the adjustments.

+
STEP 2 · new

Adjust targets

One link to targets/check?from=selection — the check is already run, suggestions showing.

+
STEP 3

Accept

Per suggestion or Accept all. Server applies the value and records an audit comment.

+
STEP 4a

Re-check

Fresh feasibility — the accepted suggestion is gone.

+
STEP 4b · from run

…& re-run

Because you came from a run, go straight to a live selection in the same click.

+
NOTE

Direct visit

Reaching targets by editing + saving (no run) shows Accept, but no re-run button.

+
+
+ +
+
04

Data flow

+

The new route re-derives the suggestions itself (never trusting posted numbers), applies each through the existing target-value service — writing the audit comment — then branches to a re-check or, only when you came from a run, a fresh selection.

+
+ + + + + + + + + + + Failed run modal + db_selection_progress + lists adjustments (text) + + + + + Adjust targets + NEW link + ?from=selection&run_id + + + + + + + accept_suggestions() + targets.py · NEW POST + re-derive · apply · branch + scope · field · then · from + + + + + + + + check_targets_detailed() + reused · re-derive suggestions + + + + update_target_value() + reused · min/max + comment + + + + + persisted targets + minmax_manual + comment + + + + + + + redirect → check_targets + then=recheck · default + + + + + start_db_select_task + then=run · from=selection + + +
+ request / apply + re-check branch + re-run branch (from run only) + reused, unchanged +
+
+
+ +
+
05

Scope & decisions

+
+
Entry point

Failed selection view. An Adjust targets link bridges to the targets check — the fix starts where the problem shows.

+
Action

Per-suggestion Accept + Accept all on the targets check, mirroring sc-447.

+
Re-run

Gated on provenance. “Accept all & re-run” only when you came from a run (from=selection).

+
Comment — open

Auto-written or review-before-save. Demonstrated both in the prototype; pick one.

+
+
+

In scope

+
    +
  • Link from the failed initial-selection view to the targets check.
  • +
  • One-click accept of a recommended min/max, server-side, with an audit comment.
  • +
  • Accept-all, and accept-all-and-re-run when arriving from a run.
  • +
+

Out of scope

+
    +
  • Side-by-side “review in context” original-vs-new edit page.
  • +
  • Anything replacement-specific (original targets, saved-number handling).
  • +
  • Changing how suggestions are computed — reused as-is.
  • +
+
+
+ +
+
06

Files touched

+

One new route, one new link, two template edits for the buttons. Feasibility and persistence are reused.

+
+
edit
templates/backoffice/components/db_selection_progress_modal.html
“Adjust targets” link on an initial-selection failure (not replacements).
+
new
blueprints/targets.py → accept_suggestions()
POST: re-derive suggestions, apply + comment via service, redirect to re-check or run.
+
edit
templates/backoffice/targets/category_block.html
Accept button beside each value-level & category-level suggestion.
+
edit
templates/backoffice/assembly_targets.html
“Accept all”, and “Accept all & re-run selection” when from=selection.
+
reuse
target_service.update_target_value()
Applies a manual min/max + comment, breaking the percentage link — existing validation.
+
reuse
target_checking.check_targets_detailed()
Authoritative suggestion source, re-run inside the new route.
+
reuse
db_selection_backoffice.start_db_select_task()
Existing Run path for the “& re-run selection” branch.
+
+
+ + + +
+ + + + diff --git a/backend/docs/agent/sc-281-recommended-target-button-plan.md b/backend/docs/agent/sc-281-recommended-target-button-plan.md new file mode 100644 index 000000000..ff8fa8cb0 --- /dev/null +++ b/backend/docs/agent/sc-281-recommended-target-button-plan.md @@ -0,0 +1,153 @@ +# sc-281 — Add an "implement recommended target changes" button (initial selection) + +## Context / the problem + +When you run the initial selection at the end of recruitment, the algorithm may tell you +that the targets cannot be met — but that with a few adjustments they can be. The selection +run **failure view tells you what adjustments to make, but gives you no way to make them +there.** You have to open the Targets tab and apply the changes by hand, and in practice +keep two tabs open: one showing the suggestions, the other the targets-editing interface. + +sc-281 closes that gap. The replacement feature (sc-447, now Done) solved the equivalent +problem for replacement selections with one-click **Accept** buttons; this brings the same +to the initial selection, wired from the place the problem actually surfaces — the failed +run. + +Branch: `281-recommended-target-button`. Companion interactive mockup: `sc-281-mockup.html`. + +## Why the targets page is the place the changes happen + +The structured, machine-readable suggestions only exist on the targets **Check** flow: + +- `check_targets_detailed()` (`backend/src/opendlp/service_layer/target_checking.py:282`), + on `InfeasibleQuotasError`, emits + `TargetAnnotation(level="suggestion", field="min"|"max", suggested_value=N)` per + category/value (`_annotations_from_infeasible_quotas`, line 189). +- A **failed selection run** only stores a plain-text `error_message` — it shows the + adjustments as prose, with no `suggested_value` to attach a button to. + +So the design is a two-part journey, not a single page: + +1. **Entry point** — the failed-run view gets a link that takes you straight to the targets + Check page *with the check already run*, so the suggestions are showing and actionable. +2. **Action** — on the targets Check page, Accept buttons apply the authoritative suggested + values and re-check (and, when you came from a run, offer to re-run the selection). + +## Decisions + +- **Entry point:** add an "Adjust targets" action to the **initial-selection failure view** + (the run progress modal) → `targets/check?from=selection&run_id=…`. This is the fix to + Hamish's main point: the standard flow starts at "run initial selection", not the targets + tab. +- **Action surface:** targets Check page — per-suggestion **Accept** + **Accept all** + (mirrors sc-447). +- **Provenance-gated re-run:** **Accept all & re-run selection** only appears when you + arrived from a selection run (`from=selection`). Reaching `targets/check` the normal way + (after editing + saving targets) shows only Accept / Accept all + re-check — no re-run + button, because there is no run to re-run. +- **Audit comment on accept:** accepting a suggestion records a comment on that target value + (e.g. *"Accepted algorithm suggestion — minimum 15 → 11"*), because accepting breaks the + percentage link and sets `minmax_manual=True`; the change should leave a trail. **Two + candidate behaviours, decision pending** — the mockup demonstrates both: + - **A. Auto-written** — comment written automatically on accept; stays a one-click + server-side save. + - **B. Review before save** — accept pre-fills the value *and* an editable comment box + and stages the edit; nothing persists until you Save. Closer to sc-447's edit-then-run + shape, but a larger behavioural change. + +## Approach + +### 1. Entry point — link from the initial-selection failure view + +In `backend/templates/backoffice/components/db_selection_progress_modal.html` (the run +progress modal; footer currently only offers **Close** once `run_record.has_finished`): +- When the run `is_failed` **and** it is an initial selection + (`task_type == select_from_db`, not the replacement task), add an **Adjust targets** + button → `url_for("targets.check_targets", assembly_id=…, from="selection", + run_id=current_selection)`. +- Guard: show it for initial-selection failures only (replacements have their own flow). +- Optional refinement: record on the run whether the failure was an infeasibility (has + suggestions) so the link only shows when there is something to accept; otherwise show it + on any failed initial run and let the Check page show what it finds. + +### 2. Action — new endpoint in `targets.py` + +Add `POST /assembly//targets/accept-suggestions` beside `check_targets` +(`backend/src/opendlp/entrypoints/blueprints/targets.py:366`): +1. Re-run `check_targets_detailed()` to get the **authoritative** current suggestions + (don't trust client-sent numbers). +2. Read form: `scope` (`one`|`all`); for `one`: `category_id`, `value_id`, `field`; + `then` (`recheck` default | `run`); carry `from`/`run_id` through for redirects. +3. Apply each targeted suggestion via existing **`update_target_value()`** + (`backend/src/opendlp/service_layer/target_service.py:530`) — override the one suggested + field (min→`suggested_value` keeping current max, or vice-versa), **and set `comment`** + to the audit string (behaviour A). Reuses `set_manual_min_max` (`domain/targets.py:167`) + and its validation; no new domain logic. Batch all applies in one `with uow:` block. +4. Redirect: `then="recheck"` → `targets.check_targets` (fresh feasibility, carrying + `from`/`run_id`); `then="run"` → start the selection the Run button's way + (`start_db_select_task`). Only valid when `from=selection`. + +Same decorators/guards/`try-except`/`flash` structure as `check_targets`. + +**Parity caveat to verify during impl:** confirm the targets-check re-derivation reproduces +the same infeasibility the run hit (same settings — same-address, test vs real, +`number_to_select`). If they can diverge, the failure view could promise adjustments the +Check page then doesn't surface. + +### 3. Templates — Accept buttons + provenance + comment modes + +`backend/templates/backoffice/targets/category_block.html`: +- Value-level suggestion (~line 182) and category-level (~line 89): **Accept** button in a + small `
` posting `scope=one`, `category_id`, `value_id`, `field`, + `then=recheck`, `from`, `run_id`, CSRF. Design-system `button` macro (`variant="tertiary"`), + matching `db_replacement_modal.html:97`. + +`backend/templates/backoffice/assembly_targets.html`: +- When `check_result` has suggestions: **Accept all suggestions** (`scope=all, + then=recheck`), and — **only if `from=selection`** — **Accept all & re-run selection** + (`scope=all, then=run`). + +Comment behaviour A is pure server-side (no JS). Behaviour B (review-before-save) would +instead stage the edit into the existing targets edit form with the comment pre-filled — +decide before building. + +### 4. i18n + +New strings (`_("Adjust targets")`, `_("Accept")`, `_("Accept all suggestions")`, +`_("Accept all & re-run selection")`, the audit-comment template) via the existing +`gettext`/`_()` path; run the repo's message-extraction step. Do not hand-edit catalogues. + +## Files to modify + +- `backend/templates/backoffice/components/db_selection_progress_modal.html` — "Adjust + targets" link on initial-selection failure. **(new entry point)** +- `backend/src/opendlp/entrypoints/blueprints/targets.py` — new `accept_suggestions` route. +- `backend/templates/backoffice/targets/category_block.html` — per-suggestion Accept forms. +- `backend/templates/backoffice/assembly_targets.html` — Accept-all + provenance-gated re-run. +- (Reused) `target_service.update_target_value` (now also writing the comment), + `target_checking.check_targets_detailed`, `db_selection_backoffice.start_db_select_task`. + +## Tests + +- **Component** (`tests/component/test_targets_pages.py`): `scope=one` applies the suggested + min/max to the right value, writes the audit comment, redirects to re-check; `scope=all` + applies all; `then=run` with `from=selection` starts a selection; the re-run button is + absent without `from=selection`. Assert stored min/max, `minmax_manual=True`, and comment. +- **Component** (`tests/component/test_db_selection_routes.py`): a failed initial-selection + run's modal shows the **Adjust targets** link; a successful / replacement run does not. +- **Unit** (`tests/unit/test_target_service.py`): apply-one-suggestion overrides only the + targeted field, leaves the other bound, and sets the comment. Mirror + `test_replacement_targets.py`. + +## Verification (end-to-end) + +1. Worktree: `npm ci` + generated_version.txt if fresh, then `just run`. +2. Run an initial selection on an assembly with infeasible targets → it fails. +3. In the failure modal, click **Adjust targets** → land on targets Check with suggestions + showing and the re-run button present (came from selection). +4. Click a single **Accept** → value updates, audit comment recorded, re-check runs, that + suggestion is gone. +5. **Accept all & re-run selection** → targets saved and a selection run starts. +6. Reach `targets/check` the normal way (edit + save) → suggestions + Accept, **no** re-run + button. +7. `just test` (or targeted pytest); BDD auth failures are pre-existing. diff --git a/backend/src/js/backoffice/alpine-components.js b/backend/src/js/backoffice/alpine-components.js index cec159ba1..1707a0225 100644 --- a/backend/src/js/backoffice/alpine-components.js +++ b/backend/src/js/backoffice/alpine-components.js @@ -18,6 +18,7 @@ import { progressModalDemo, } from "../components/showcase-demos.js"; import { showcaseNav } from "../components/showcase-nav.js"; +import { suggestionRow } from "../components/suggestion-row.js"; import { tabsKeyboard } from "../components/tabs-keyboard.js"; import { targetsPage } from "../components/targets-page.js"; import { urlSelect } from "../components/url-select.js"; @@ -46,6 +47,7 @@ document.addEventListener("alpine:init", function () { Alpine.data("replacementSuggestions", replacementSuggestions); Alpine.data("menuButton", menuButton); Alpine.data("showcaseNav", showcaseNav); + Alpine.data("suggestionRow", suggestionRow); Alpine.data("tabsKeyboard", tabsKeyboard); Alpine.data("targetsPage", targetsPage); Alpine.data("urlSelect", urlSelect); diff --git a/backend/src/js/components/suggestion-row.js b/backend/src/js/components/suggestion-row.js new file mode 100644 index 000000000..202121325 --- /dev/null +++ b/backend/src/js/components/suggestion-row.js @@ -0,0 +1,103 @@ +// ABOUTME: Alpine component for a suggestion row that writes its change into inputs on the page +// ABOUTME: Accept fills the named inputs and the comment in one click; Undo puts them back + +/** + * Build the state for one suggestion row in "apply" mode. + * + * The row is given, as data attributes, the inputs its suggestion changes and + * the values to put in them (`data-apply`, a JSON map of input id to value), + * and the input its comment goes into (`data-comment-target`). Accepting + * writes all of them at once and fires `input` on each, so whatever listens - + * a category's totals, the page's unsaved-changes guard - hears of it as if it + * had been typed. Nothing is sent anywhere; the page's own Save does that. + * + * Two suggestions can share a comment input - a minimum and a maximum on one + * value. The second one accepted adds its comment to the first rather than + * replacing it, so neither reason is lost. + * + * @returns {Object} Alpine component state + */ +export function suggestionRow() { + return { + accepted: false, + previous: {}, + written: {}, + commentWasSuggested: false, + + accept: function () { + if (this.accepted) return; + var apply = parseApply(this.$root.getAttribute("data-apply")); + var previous = {}; + var written = {}; + + Object.keys(apply).forEach(function (id) { + var field = document.getElementById(id); + if (!field) return; + previous[id] = field.value; + written[id] = String(apply[id]); + setValue(field, written[id]); + }); + + var commentId = this.$root.getAttribute("data-comment-target"); + var commentField = commentId ? document.getElementById(commentId) : null; + var comment = this.$refs.comment ? this.$refs.comment.value.trim() : ""; + if (commentField && comment) { + previous[commentId] = commentField.value; + this.commentWasSuggested = + commentField.getAttribute("data-suggestion-comment") === "true"; + written[commentId] = combineComment(commentField, comment); + setValue(commentField, written[commentId]); + commentField.setAttribute("data-suggestion-comment", "true"); + } + + this.previous = previous; + this.written = written; + this.accepted = true; + }, + + /** + * Put back what Accept replaced - but only in an input still holding what + * Accept wrote. One the user has typed over since is theirs, not ours. + */ + undo: function () { + var previous = this.previous; + var written = this.written; + var commentId = this.$root.getAttribute("data-comment-target"); + var commentWasSuggested = this.commentWasSuggested; + Object.keys(previous).forEach(function (id) { + var field = document.getElementById(id); + if (!field || field.value !== written[id]) return; + setValue(field, previous[id]); + if (id === commentId && !commentWasSuggested) { + field.removeAttribute("data-suggestion-comment"); + } + }); + this.previous = {}; + this.written = {}; + this.accepted = false; + }, + }; +} + +function parseApply(raw) { + if (!raw) return {}; + try { + return JSON.parse(raw) || {}; + } catch (e) { + // A malformed map has nothing usable in it; accepting it changes nothing. + return {}; + } +} + +function combineComment(field, comment) { + var current = field.value.trim(); + if (current && field.getAttribute("data-suggestion-comment") === "true") { + return current + "; " + comment; + } + return comment; +} + +function setValue(field, value) { + field.value = value; + field.dispatchEvent(new Event("input", { bubbles: true })); +} diff --git a/backend/src/js/components/suggestion-row.test.js b/backend/src/js/components/suggestion-row.test.js new file mode 100644 index 000000000..a80139b9a --- /dev/null +++ b/backend/src/js/components/suggestion-row.test.js @@ -0,0 +1,95 @@ +// ABOUTME: Unit tests for the suggestionRow Alpine component +// ABOUTME: Covers one-click accept into the page's inputs, combining comments, and undo + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { suggestionRow } from "./suggestion-row.js"; + +function setUp() { + document.body.innerHTML = ` + + + + + + + + + + +
`; +} + +function rowState(rowId, commentId) { + const state = suggestionRow(); + state.$root = document.getElementById(rowId); + state.$refs = { comment: document.getElementById(commentId) }; + return state; +} + +const value = (id) => document.getElementById(id).value; + +beforeEach(setUp); + +describe("suggestionRow", () => { + it("writes the suggested number and the comment in one click", () => { + const state = rowState("min-row", "min-comment"); + state.accept(); + expect(value("min")).toBe("3"); + expect(value("comment")).toBe("Accepted minimum 5 → 3"); + expect(state.accepted).toBe(true); + }); + + it("saves the comment as edited before accepting", () => { + document.getElementById("min-comment").value = "Too few young men"; + rowState("min-row", "min-comment").accept(); + expect(value("comment")).toBe("Too few young men"); + }); + + it("fires input on what it changes, so totals and the leave guard hear", () => { + const heard = vi.fn(); + document.body.addEventListener("input", heard); + rowState("min-row", "min-comment").accept(); + expect(heard).toHaveBeenCalledTimes(2); + }); + + it("adds a second suggestion's comment to the first rather than replacing it", () => { + rowState("min-row", "min-comment").accept(); + rowState("max-row", "max-comment").accept(); + expect(value("comment")).toBe( + "Accepted minimum 5 → 3; Accepted maximum 9 → 6", + ); + }); + + it("replaces a comment the organiser wrote earlier", () => { + document.getElementById("comment").value = "An older reason"; + rowState("min-row", "min-comment").accept(); + expect(value("comment")).toBe("Accepted minimum 5 → 3"); + }); + + it("puts everything back on undo", () => { + document.getElementById("comment").value = "An older reason"; + const state = rowState("min-row", "min-comment"); + state.accept(); + state.undo(); + expect(value("min")).toBe("5"); + expect(value("comment")).toBe("An older reason"); + expect(state.accepted).toBe(false); + }); + + it("leaves alone on undo what was typed over after accepting", () => { + const state = rowState("min-row", "min-comment"); + state.accept(); + document.getElementById("min").value = "4"; + state.undo(); + expect(value("min")).toBe("4"); + }); + + it("does nothing the second time it is accepted", () => { + const state = rowState("min-row", "min-comment"); + state.accept(); + document.getElementById("min").value = "4"; + state.accept(); + expect(value("min")).toBe("4"); + }); +}); diff --git a/backend/src/js/components/targets-page.js b/backend/src/js/components/targets-page.js index 9899b2c99..1d001cec0 100644 --- a/backend/src/js/components/targets-page.js +++ b/backend/src/js/components/targets-page.js @@ -75,6 +75,14 @@ export function targetsPage(options) { this.openLeaveModal(this.discardUrl); }, + /** + * Accept every suggestion row on the page at once. Each one fills its own + * value row in; nothing is saved until the form is. + */ + acceptAllSuggestions: function () { + window.dispatchEvent(new CustomEvent("suggestions-accept-all")); + }, + openAddTarget: function () { this.newCategoryName = ""; this.addTargetOpen = true; diff --git a/backend/src/opendlp/entrypoints/blueprints/targets.py b/backend/src/opendlp/entrypoints/blueprints/targets.py index 6a9ae65ae..7b99c4180 100644 --- a/backend/src/opendlp/entrypoints/blueprints/targets.py +++ b/backend/src/opendlp/entrypoints/blueprints/targets.py @@ -20,6 +20,7 @@ get_assembly_gsheet, get_assembly_with_permissions, get_csv_upload_status, + get_or_create_csv_config, get_tab_enabled_states, ) from opendlp.service_layer.constants import MAX_DISTINCT_VALUES_FOR_AUTO_ADD @@ -32,7 +33,8 @@ ) from opendlp.service_layer.permissions import can_manage_assembly from opendlp.service_layer.respondent_service import get_respondent_attribute_value_counts -from opendlp.service_layer.target_checking import check_targets_detailed +from opendlp.service_layer.sortition import start_db_select_task +from opendlp.service_layer.target_checking import DetailedCheckResult, check_targets_detailed from opendlp.service_layer.target_csv_import import import_targets_from_csv from opendlp.service_layer.target_respondent_helpers import ( build_respondent_counts, @@ -49,6 +51,7 @@ delete_targets_for_assembly, get_targets_for_assembly, save_all_targets, + update_target_value, ) from opendlp.translations import gettext as _ @@ -395,6 +398,12 @@ def check_targets(assembly_id: uuid.UUID) -> ResponseReturnValue: all_selected_counts=selected_counts, has_selected=has_selected, check_result=check_result, + # Carried through from a failed selection run (see accept_suggestions), + # so the page offers to re-run and keeps the link alive across rechecks. + selection_provenance=_selection_provenance(request.args), + # "Adjust targets" on a failed run opens straight into the editor, + # with each suggestion under the row it would change. + editing_all=can_manage and request.args.get("edit") == "1", **context, ), 200 @@ -410,6 +419,198 @@ def check_targets(assembly_id: uuid.UUID) -> ResponseReturnValue: return redirect(url_for("targets.view_assembly_targets", assembly_id=assembly_id)) +def _suggestion_change_phrase(field: str, current: int, suggested: int) -> str: + """The "minimum X → Y" fragment shared by the single and combined comments.""" + if field == "min": + return _("minimum %(current)s → %(suggested)s", current=current, suggested=suggested) + return _("maximum %(current)s → %(suggested)s", current=current, suggested=suggested) + + +def _suggestion_annotations(check: DetailedCheckResult, category_name: str, value_name: str) -> list: + """The applicable min/max suggestions the check holds for one value.""" + return [ + annotation + for annotation in check.annotations.get(category_name, {}).get(value_name, []) + if annotation.level == "suggestion" and annotation.suggested_value is not None + ] + + +def _apply_value_suggestions( + uow: Any, + assembly_id: uuid.UUID, + category: Any, + value: Any, + annotations: list, + comment: str, +) -> None: + """Apply every min/max suggestion for one value in a single update. + + Passing only the fields that change leaves the other bound (see + `_apply_value_numbers`); accepting a suggestion is a manual override, so this + breaks the percentage link and records why in the comment. + """ + min_count = next((a.suggested_value for a in annotations if a.field == "min"), None) + max_count = next((a.suggested_value for a in annotations if a.field == "max"), None) + update_target_value( + uow, + current_user.id, + assembly_id, + category.id, + value.value_id, + value.value, + min_count=min_count, + max_count=max_count, + comment=comment, + ) + + +def _accept_one( + uow: Any, + assembly_id: uuid.UUID, + categories: list, + check: DetailedCheckResult, + form: Any, +) -> bool: + """Apply the single suggestion the form names, with the organiser's comment.""" + try: + category_id = uuid.UUID(form.get("category_id", "")) + value_id = uuid.UUID(form.get("value_id", "")) + except ValueError: + return False + field = form.get("field", "") + category = next((c for c in categories if c.id == category_id), None) + value = next((v for v in (category.values or []) if v.value_id == value_id), None) if category else None + if category is None or value is None: + return False + + annotations = [a for a in _suggestion_annotations(check, category.name, value.value) if a.field == field] + if not annotations: + return False + + suggested = annotations[0].suggested_value + current = value.min if field == "min" else value.max + comment = form.get("comment", "").strip() or _( + "Accepted algorithm suggestion — %(change)s", + change=_suggestion_change_phrase(field, current, suggested), + ) + _apply_value_suggestions(uow, assembly_id, category, value, annotations, comment) + return True + + +def _accept_all( + uow: Any, + assembly_id: uuid.UUID, + categories: list, + check: DetailedCheckResult, +) -> bool: + """Apply every outstanding suggestion, each with an auto-written comment.""" + applied = False + for category in categories: + for value in category.values or []: + annotations = _suggestion_annotations(check, category.name, value.value) + if not annotations: + continue + changes = ", ".join( + _suggestion_change_phrase(a.field, value.min if a.field == "min" else value.max, a.suggested_value) + for a in sorted(annotations, key=lambda a: a.field) + ) + comment = _("Accepted algorithm suggestion — %(changes)s", changes=changes) + _apply_value_suggestions(uow, assembly_id, category, value, annotations, comment) + applied = True + return applied + + +def _start_rerun(uow: Any, assembly_id: uuid.UUID, provenance: dict[str, Any]) -> uuid.UUID | None: + """Re-run the kind of selection that failed, unless its settings still need confirming. + + A failed test selection is re-run as a test selection, never as the real one. + """ + csv_config = get_or_create_csv_config(uow, current_user.id, assembly_id) + if csv_config.settings_confirmed: + task_id: uuid.UUID = start_db_select_task( + uow, current_user.id, assembly_id, test_selection=provenance.get("test") == "1" + ) + return task_id + flash(_("Please review and save the selection settings before running selection"), "warning") + return None + + +def _redirect_after_save(assembly_id: uuid.UUID, task_id: uuid.UUID | None, recheck_url: str) -> ResponseReturnValue: + """To the new selection run if one was started, otherwise back to the check.""" + if task_id is not None: + return redirect(url_for("gsheets.view_assembly_selection", assembly_id=assembly_id, current_selection=task_id)) + return redirect_preserving_scroll(recheck_url) + + +def _selection_provenance(params: Any) -> dict[str, Any]: + """What marks a visit as coming from a failed selection run, as url or form params. + + ``from=selection`` and the ``run_id``, plus ``test=1`` when the run was a test + selection - the same flag the selection page starts a test run with - so a + re-run starts the same kind of selection that failed. Empty for any other + visit, so it can be splatted into ``url_for`` or rendered as hidden fields. + """ + if params.get("from") != "selection": + return {} + provenance = {"from": "selection", "run_id": params.get("run_id", "")} + if params.get("test") == "1": + provenance["test"] = "1" + return provenance + + +@targets_bp.route("/assembly//targets/accept-suggestions", methods=["POST"]) +@login_required +def accept_suggestions(assembly_id: uuid.UUID) -> ResponseReturnValue: + """Apply one or all of the algorithm's recommended target changes. + + The suggestions are re-derived here rather than trusted from the form, so a + stale page cannot apply a number the check no longer recommends. Each applied + value gets a comment recording where the change came from; for a single + accept the organiser may have edited that comment before saving. + + ``then=run`` re-runs the failed selection after saving - a test run as a test + run - and is only honoured when the organiser arrived from one + (``from=selection``). + """ + provenance = _selection_provenance(request.form) + run_after = bool(provenance) and request.form.get("then") == "run" + recheck_url = url_for("targets.check_targets", assembly_id=assembly_id, **provenance) + + try: + uow = bootstrap.get_flask_uow() + task_id = None + with uow: + get_assembly_with_permissions(uow, assembly_id, current_user.id) + check = check_targets_detailed(uow, current_user.id, assembly_id) + categories = get_targets_for_assembly(uow, current_user.id, assembly_id) + + if request.form.get("scope") == "all": + applied = _accept_all(uow, assembly_id, categories, check) + else: + applied = _accept_one(uow, assembly_id, categories, check, request.form) + + if not applied: + flash(_("That suggestion is no longer available. The targets have been re-checked."), "warning") + return redirect_preserving_scroll(recheck_url) + + if run_after: + task_id = _start_rerun(uow, assembly_id, provenance) + + flash(_("Recommended changes applied."), "success") + return _redirect_after_save(assembly_id, task_id, recheck_url) + + except NotFoundError: + flash(_("Assembly not found"), "error") + return redirect(url_for("backoffice.dashboard")) + except InsufficientPermissions: + flash(_("You don't have permission to manage this assembly"), "error") + return redirect(url_for("backoffice.dashboard")) + except Exception as e: + logger.exception("Error accepting target suggestions", assembly_id=str(assembly_id), error=str(e)) + flash(_("An unexpected error occurred while applying the recommended changes"), "error") + return redirect(url_for("targets.view_assembly_targets", assembly_id=assembly_id)) + + def _render_targets_edit_errors( assembly_id: uuid.UUID, form_data: Any, @@ -439,6 +640,7 @@ def _render_targets_edit_errors( pending_categories=pending_categories(form_data), field_errors=errors_by_field(errors), editing_all=True, + selection_provenance=_selection_provenance(form_data), value_form=TargetValueForm(), can_manage=_can_manage(assembly_id), all_respondent_counts=build_respondent_counts(assembly_id, target_categories, attribute_columns), @@ -492,6 +694,9 @@ def save_all(assembly_id: uuid.UUID) -> ResponseReturnValue: if errors: return _render_targets_edit_errors(assembly_id, request.form, errors) + provenance = _selection_provenance(request.form) + run_after = bool(provenance) and request.form.get("then") == "run" + task_id = None uow = bootstrap.get_flask_uow() with uow: save_all_targets( @@ -501,11 +706,15 @@ def save_all(assembly_id: uuid.UUID) -> ResponseReturnValue: edits, force_unlink=request.form.get("force_unlink") == "1", ) + if run_after: + task_id = _start_rerun(uow, assembly_id, provenance) flash(_("Targets saved"), "success") # The detailed check is the whole point of saving: land on the page that # runs it, so its annotations arrive without anyone asking for them. - return redirect(url_for("targets.check_targets", assembly_id=assembly_id)) + return _redirect_after_save( + assembly_id, task_id, url_for("targets.check_targets", assembly_id=assembly_id, **provenance) + ) except TargetLinkedError as e: return _render_force_unlink_confirm(assembly_id, request.form, e.blocks) diff --git a/backend/src/opendlp/service_layer/target_checking.py b/backend/src/opendlp/service_layer/target_checking.py index a4bb920df..def006e45 100644 --- a/backend/src/opendlp/service_layer/target_checking.py +++ b/backend/src/opendlp/service_layer/target_checking.py @@ -65,6 +65,20 @@ class DetailedCheckResult: num_features: int = 0 num_people: int = 0 + @property + def has_suggestions(self) -> bool: + """True when any value carries an applicable min/max suggestion. + + Drives whether the targets page offers the "accept recommended changes" + actions: there is nothing to accept unless the solver relaxed a quota. + """ + return any( + annotation.level == "suggestion" and annotation.suggested_value is not None + for value_annotations in self.annotations.values() + for annotations in value_annotations.values() + for annotation in annotations + ) + def _add_annotation(annotations: AnnotationsDict, category: str, value: str, annotation: TargetAnnotation) -> None: if category not in annotations: diff --git a/backend/templates/backoffice/assembly_targets.html b/backend/templates/backoffice/assembly_targets.html index 6225ade8e..135245a48 100644 --- a/backend/templates/backoffice/assembly_targets.html +++ b/backend/templates/backoffice/assembly_targets.html @@ -74,9 +74,21 @@ discardUrl is passed in rather than read from the address bar: a rejected save re-renders this page at the save-all POST url, which would 405 on the GET a reload makes. #} + {# Where a failed selection run sent the organiser from - from, run_id and, + for a test run, test - carried on every form and link that leads back + here, so a re-run starts the same kind of selection. Empty otherwise. #} + {% set provenance = selection_provenance|default({}) %} + {% set rerun_is_test = provenance.get("test") == "1" %} + {# Discarding edits made against the check's suggestions goes back to the + check, suggestions and all, rather than to a page without them. #} + {% if check_result is defined and check_result %} + {% set discard_url = url_for('targets.check_targets', assembly_id=assembly.id, **provenance) %} + {% else %} + {% set discard_url = url_for('targets.view_assembly_targets', assembly_id=assembly.id) %} + {% endif %}
{# ── Targets Heading and Actions ──────────────── #}
@@ -143,6 +155,49 @@

{{ _("Ta

{% endif %} + {# ── Accept recommended changes ──────────────────── #} + {# Both views carry the suggestions, so both get the page-level + actions. Read-only, they save at once. Editing, "Accept all" only + fills the rows in - the save bar keeps them, or saves and re-runs. + Re-running is offered only to an organiser who arrived from a + failed selection run. #} + {% if can_manage and check_result is defined and check_result and check_result.has_suggestions %} +
+
+ + {{ _("The algorithm has recommended target changes. Accept them individually below, or all at once here.") }} + + + {{ button(_("Accept all suggestions"), variant="secondary", attrs='@click="acceptAllSuggestions()"') }} + + + + + + + {% for name, value in provenance.items() %}{% endfor %} + {{ button(_("Accept all suggestions"), type="submit", variant="secondary") }} + + {% if provenance %} +
+ + + + {% for name, value in provenance.items() %}{% endfor %} + {{ button(_("Accept all & re-run test selection") if rerun_is_test else _("Accept all & re-run selection"), type="submit", variant="primary") }} +
+ {% endif %} +
+
+
+ {% endif %} + {# ── Category Blocks ─────────────────────────────── #} {# Rendered even with nothing to edit yet: "Add target" clones into this form, so it has to be here before the first target is. #} @@ -179,6 +234,9 @@

{{ _("Ta diff --git a/backend/templates/backoffice/components/db_selection_progress_modal.html b/backend/templates/backoffice/components/db_selection_progress_modal.html index 56a305d94..4f1d0bc18 100644 --- a/backend/templates/backoffice/components/db_selection_progress_modal.html +++ b/backend/templates/backoffice/components/db_selection_progress_modal.html @@ -185,4 +185,19 @@

{{ modal_footer_end() }} {% endif %} +{# A failed initial or test selection usually means the targets cannot be met. Send the + organiser straight to the targets check, with the feasibility suggestions + showing and ready to accept, rather than leaving them at a dead end. #} +{% if run_record.is_failed and run_record.task_type.value in ('select_from_db', 'test_select_from_db') %} + {# test=1 is how the targets page knows to re-run a test selection, not the real one. #} + {% set adjust_args = {'from': 'selection', 'run_id': current_selection, 'edit': '1'} %} + {% if is_test_selection %}{% set adjust_args = dict(adjust_args, test='1') %}{% endif %} + {{ modal_footer_start() }} +
+ {{ button(_("Adjust targets") , + href=url_for('targets.check_targets', assembly_id=assembly.id, **adjust_args), + variant="primary") }} +
+ {{ modal_footer_end() }} +{% endif %} {% endcall %} diff --git a/backend/templates/backoffice/components/suggestion_row.html b/backend/templates/backoffice/components/suggestion_row.html new file mode 100644 index 000000000..fa3d500f9 --- /dev/null +++ b/backend/templates/backoffice/components/suggestion_row.html @@ -0,0 +1,110 @@ +{# +ABOUTME: A table row offering one suggested change, accepted with a single click +ABOUTME: Accept either posts to the server or writes the change into inputs elsewhere on the page +#} +{% from "backoffice/components/button.html" import button %} +{% from "backoffice/components/input.html" import input %} +{# + One suggestion - "set the minimum to 3" - as a full-width row under the table + row it is about. + + The comment that will be saved with the change is on show and editable from + the start, so accepting is one click: there is no step that opens an editor + and a second one that saves it. + + What Accept does depends on what is passed: + + action - the row is a form; Accept posts the comment, the csrf token and + `hidden_fields` there. For a page that saves each change as it + is made. + apply - {input id: value}. Accept writes each value into that input on + the page, and the comment into the input `comment_target` names, + firing `input` on each so totals and dirty-tracking hear of it. + Nothing is saved; the page's own Save does that. Accepted rows + can be undone, and all of them accept at once on a + `suggestions-accept-all` window event. + neither - the suggestion is shown with nothing to accept, as for someone + who may not change what it is about. + + Args: + message: what is suggested, in words + colspan: how many columns of the surrounding table the row spans + comment: the comment saved with the change, pre-filled and editable + comment_id: id of the comment input (needed when it is shown) + action: url Accept posts to + hidden_fields: {name: value} posted with the comment + form_attrs: extra attributes for the form, in action mode + apply: {input id: value} written in on Accept, in apply mode + comment_target: id of the input the comment is written into + accept_label: the Accept button's text + + Usage: + {{ suggestion_row(_("Suggested minimum: 3"), colspan=6, + comment="Accepted algorithm suggestion", comment_id="c-1", + action=url_for(...), hidden_fields={"field": "min"}) }} + {{ suggestion_row(_("Suggested minimum: 3"), colspan=6, + comment="Accepted algorithm suggestion", comment_id="c-1", + apply={"row-1-min": 3}, comment_target="row-1-comment") }} +#} +{% macro suggestion_row(message, colspan=1, comment="", comment_id="", action="", hidden_fields={}, form_attrs="", apply={}, comment_target="", accept_label="") %} + {% set applies = apply | length > 0 %} + {% set actionable = action or applies %} + {% set accept_text = accept_label or _("Accept") %} + {% set comment_label = _("Comment saved with this change") %} + {# tojson escapes quotes and angle brackets, so the map is safe inside a + single-quoted attribute - and Alpine never has to parse it. #} + + +
+

+ {{ _("Suggestion") }} + {{ message }} +

+ {% if actionable %} + {% set controls %} + +
+ {# Unnamed in apply mode: it is copied into the row, and must not + be submitted with whatever form the row sits in. #} + {{ input(name="comment" if action else "", + id=comment_id, + value=comment, + attrs='x-ref="comment"' if applies else "") }} +
+ {% if action %} + {{ button(accept_text, type="submit", variant="secondary") }} + {% else %} + {{ button(accept_text, variant="secondary", attrs='@click="accept()"') }} + {% endif %} + {% endset %} + {% if action %} +
+ + {% for name, value in hidden_fields.items() %} + + {% endfor %} + {{ controls }} +
+ {% else %} +
{{ controls }}
+
+ {{ _("Accepted. Save to keep the change.") }} + {{ button(_("Undo"), variant="tertiary", attrs='@click="undo()"') }} +
+ {% endif %} + {% endif %} +
+ + +{% endmacro %} diff --git a/backend/templates/backoffice/showcase.html b/backend/templates/backoffice/showcase.html index bb4ba1aee..9a40e3140 100644 --- a/backend/templates/backoffice/showcase.html +++ b/backend/templates/backoffice/showcase.html @@ -22,6 +22,7 @@ {% from "backoffice/showcase/tabs_component.html" import tabs_section %} {% from "backoffice/showcase/stepper_component.html" import stepper_section %} {% from "backoffice/showcase/alert_component.html" import alert_section %} +{% from "backoffice/showcase/suggestion_row_component.html" import suggestion_row_section %} {% from "backoffice/showcase/search_dropdown_component.html" import search_dropdown_section %} {% from "backoffice/showcase/select_dropdown_component.html" import select_dropdown_section %} {% from "backoffice/showcase/modal_component.html" import modal_section %} @@ -111,6 +112,7 @@

Design System< + @@ -177,6 +179,9 @@

Design System<
{{ tabs_section() }}
{{ stepper_section() }}
{{ alert_section() }}
+
{{ suggestion_row_section() }}
{{ interactivity_section() }}
diff --git a/backend/templates/backoffice/showcase/suggestion_row_component.html b/backend/templates/backoffice/showcase/suggestion_row_component.html new file mode 100644 index 000000000..0e72780c0 --- /dev/null +++ b/backend/templates/backoffice/showcase/suggestion_row_component.html @@ -0,0 +1,88 @@ +{# +ABOUTME: Showcase section for the suggestion row component +ABOUTME: Shows the message-only, save-on-accept and fill-in-on-accept forms of the row +#} +{% from "backoffice/components/showcase_section.html" import showcase_section, showcase_title, showcase_description, showcase_subtitle, showcase_example, showcase_tokens %} +{% from "backoffice/components/suggestion_row.html" import suggestion_row %} +{% from "backoffice/components/input.html" import input %} + +{% macro suggestion_row_section() %} + {% call showcase_section() %} + {{ showcase_title("Suggestion Row") }} + {{ showcase_description("A full-width table row offering one suggested change to the row above it. The comment saved with the change is pre-filled and editable from the start, so accepting is a single click. Used for the algorithm's target suggestions.") }} + + {{ showcase_subtitle("Fill in on accept (edit forms)") }} +

+ Pass apply - a map of input id to value - and comment_target. Accept writes them into the + row above and fires input on each; the page's own Save keeps them. Undo puts back whatever has not been + typed over since. A suggestions-accept-all window event accepts every such row on the page. +

+ {% call showcase_example('{% from "backoffice/components/suggestion_row.html" import suggestion_row %} + + {{ suggestion_row(_("Suggested minimum: 3"), + colspan=3, + comment=_("Accepted algorithm suggestion — minimum 5 → 3"), + comment_id="accept-comment-1", + apply={"row-1-min": 3}, + comment_target="row-1-comment") }}', transparent=true) %} + + + + + + + + + + + + + + + {{ suggestion_row("Suggested minimum: 3", + colspan=3, + comment="Accepted algorithm suggestion — minimum 5 → 3", + comment_id="showcase-suggestion-apply", + apply={"showcase-suggestion-min": 3}, + comment_target="showcase-suggestion-comment") }} + +
ValueMinComment
Male{{ input(name="", id="showcase-suggestion-min", type="number", value=5, classes="w-20", aria_label="Minimum for Male") }}{{ input(name="", id="showcase-suggestion-comment", aria_label="Comment on Male") }}
+ {% endcall %} + + {{ showcase_subtitle("Save on accept (read-only views)") }} +

+ Pass action and hidden_fields. The row is a form: Accept posts the comment, the csrf + token and the hidden fields. (Submitting is switched off here.) +

+ {% call showcase_example('{{ suggestion_row(_("Suggested maximum: 6"), + colspan=3, + comment=_("Accepted algorithm suggestion — maximum 4 → 6"), + comment_id="accept-comment-2", + action=url_for("targets.accept_suggestions", assembly_id=assembly_id), + hidden_fields={"scope": "one", "field": "max", ...}) }}', transparent=true) %} + + + {{ suggestion_row("Suggested maximum: 6", + colspan=3, + comment="Accepted algorithm suggestion — maximum 4 → 6", + comment_id="showcase-suggestion-action", + action="#", + form_attrs="x-data @submit.prevent", + hidden_fields={"field": "max"}) }} + +
+ {% endcall %} + + {{ showcase_subtitle("Message only") }} +

+ With neither, the suggestion is shown with nothing to accept - for someone who may not make the change. +

+ {% call showcase_example('{{ suggestion_row(_("Suggested minimum: 3"), colspan=3) }}', transparent=true) %} + + {{ suggestion_row("Suggested minimum: 3", colspan=3) }} +
+ {% endcall %} + + {{ showcase_tokens(["--color-warning-100", "--color-warning-400", "--color-warning-600", "--color-secondary-text"]) }} +{% endcall %} +{% endmacro %} diff --git a/backend/templates/backoffice/targets/bulk_edit_form.html b/backend/templates/backoffice/targets/bulk_edit_form.html index 2202e6dac..ef5883346 100644 --- a/backend/templates/backoffice/targets/bulk_edit_form.html +++ b/backend/templates/backoffice/targets/bulk_edit_form.html @@ -7,6 +7,7 @@ {% from "backoffice/components/info_icon.html" import info_icon %} {% from "backoffice/components/input.html" import input %} {% from "backoffice/targets/missing_values.html" import missing_values_summary %} +{% from "backoffice/targets/value_suggestions.html" import value_suggestion_rows %} {# ──────────────────────────────────────────────────────────────────────── The row controls come from the Figma at node 4908:15647. They are sized and coloured by `.btn-icon svg`, so they are drawn with no class of their own and @@ -157,7 +158,7 @@ {% endmacro %} -{% macro category_block(cat_id, category=None, missing_values=None, errors={}, respondent_counts=None, selected_counts=None, first_row_id="") %} +{% macro category_block(cat_id, category=None, missing_values=None, errors={}, respondent_counts=None, selected_counts=None, first_row_id="", value_annotations={}) %} {% set cat_prefix = "cat[" ~ cat_id ~ "]" %} {# Both count columns are only worth a column when there is something to count, so a category with no matching respondent data shows neither. #} @@ -283,6 +284,11 @@ {% for val in (category.values if category else []) or [] %} {{ value_row(cat_id, val.value_id, val=val, errors=errors, respondent_counts=respondent_counts, selected_counts=selected_counts, show_counts=show_counts, show_selected=show_selected) }} + {# The check's suggestions, right under the row they would change. + Accept writes into that row's inputs; Save keeps it. #} + {{ value_suggestion_rows(category, val, value_annotations.get(val.value, []), column_count, + can_manage=true, + field_prefix="bulk-" ~ cat_id ~ "-" ~ val.value_id) }} {% endfor %} {# A target with no values is not worth saving, so a block cloned for a new one arrives with the row its author is @@ -373,6 +379,11 @@ @targets-changed="markEditDirty()" @submit="allowLeave()"> + {# Arriving from a failed selection run is carried through the save, so the + page it lands on can still offer to re-run - and "Save & re-run" can. #} + {% for name, value in (selection_provenance|default({})).items() %} + + {% endfor %}
{% for category in form_categories %} {% set respondent_counts = all_respondent_counts.get(category.name) if all_respondent_counts is defined and all_respondent_counts else None %} @@ -386,8 +397,9 @@ {% endif %} {% endfor %} {% endif %} + {% set value_annotations = check_result.annotations.get(category.name, {}) if check_result is defined and check_result else {} %} {{ category_block(category.id, category=category, missing_values=missing_values, errors=errors, - respondent_counts=respondent_counts, selected_counts=selected_counts) }} + respondent_counts=respondent_counts, selected_counts=selected_counts, value_annotations=value_annotations) }} {% endfor %}
{# Blank category blocks are cloned from here by the "Add target" dialog; diff --git a/backend/templates/backoffice/targets/category_block.html b/backend/templates/backoffice/targets/category_block.html index 4aab71063..e3fc0f6ec 100644 --- a/backend/templates/backoffice/targets/category_block.html +++ b/backend/templates/backoffice/targets/category_block.html @@ -4,6 +4,7 @@ #} {% from "backoffice/components/icons.html" import icon_hand, icon_sparkles %} {% from "backoffice/components/info_icon.html" import info_icon %} +{% from "backoffice/targets/value_suggestions.html" import value_suggestion_rows %} {% from "backoffice/targets/missing_values.html" import missing_values_summary %} {% macro provenance_icon(manual) %} {% if manual %} @@ -88,12 +89,12 @@

{{ category.na {% elif ann.level == "suggestion" %}
+ style="background-color: var(--color-warning-100); + border: 1px solid var(--color-warning-400)"> {{ _("Suggestion") }} - {{ ann.message }} + {{ ann.message }}
{% endif %} {% endfor %} @@ -172,19 +173,15 @@

{{ category.na {% if val.comment %}{{ val.comment | linkify }}{% endif %} - {# ── Value Annotations Row ── #} - {% if value_annotations is defined and value_annotations.get(val.value) %} + {# ── Value Annotations Rows ── #} + {% set val_annotations = value_annotations.get(val.value, []) if value_annotations is defined and value_annotations else [] %} + {% set notes = val_annotations | rejectattr("level", "equalto", "suggestion") | list %} + {% if notes %} - {% for ann in value_annotations[val.value] %} + {% for ann in notes %} {% if ann.level == "error" %}

{{ ann.message }}

- {% elif ann.level == "suggestion" %} -

- {{ _("Suggestion") }} {{ ann.message }} -

{% elif ann.level == "warning" %}

{{ category.na {% endif %} + {{ value_suggestion_rows(category, val, val_annotations, col_count, + can_manage=can_manage, + assembly_id=assembly_id, + selection_provenance=selection_provenance|default({})) }} {% endfor %} {% if not category.values %} diff --git a/backend/templates/backoffice/targets/value_suggestions.html b/backend/templates/backoffice/targets/value_suggestions.html new file mode 100644 index 000000000..d726f5f2e --- /dev/null +++ b/backend/templates/backoffice/targets/value_suggestions.html @@ -0,0 +1,52 @@ +{# +ABOUTME: The algorithm's min/max suggestions for one target value, as suggestion rows +ABOUTME: Shared by the read-only view (accept saves) and the edit form (accept fills the row in) +#} +{% from "backoffice/components/suggestion_row.html" import suggestion_row %} +{# + One suggestion_row per suggestion the check made for this value. + + In the read-only view (`field_prefix` empty) Accept posts straight to + accept_suggestions, which re-derives the suggestion and saves it. In the + edit form `field_prefix` is the id stem of the value's inputs, and Accept + writes the number and the comment into them, to be saved with everything + else. + + Someone who may not change the targets sees the suggestion and no Accept. +#} +{% macro value_suggestion_rows(category, val, annotations, colspan, can_manage=false, assembly_id="", field_prefix="", selection_provenance={}) %} + {% for ann in annotations if ann.level == "suggestion" %} + {% set acceptable = can_manage and ann.suggested_value is not none and ann.field in ("min", "max") %} + {% set current_value = val.min if ann.field == "min" else val.max %} + {% if ann.field == "min" %} + {% set default_comment = _("Accepted algorithm suggestion — minimum %(current)s → %(suggested)s", current=current_value, suggested=ann.suggested_value) %} + {% else %} + {% set default_comment = _("Accepted algorithm suggestion — maximum %(current)s → %(suggested)s", current=current_value, suggested=ann.suggested_value) %} + {% endif %} + {% set comment_id = "accept-comment-" ~ val.value_id ~ "-" ~ ann.field ~ ("-edit" if field_prefix else "") %} + {% if not acceptable %} + {{ suggestion_row(ann.message, colspan=colspan) }} + {% elif field_prefix %} + {{ suggestion_row(ann.message, + colspan=colspan, + comment=default_comment, + comment_id=comment_id, + apply={field_prefix ~ "-" ~ ann.field: ann.suggested_value}, + comment_target=field_prefix ~ "-comment") }} + {% else %} + {{ suggestion_row(ann.message, + colspan=colspan, + comment=default_comment, + comment_id=comment_id, + action=url_for('targets.accept_suggestions', assembly_id=assembly_id), + form_attrs="x-data x-preserve-scroll-on-submit", + hidden_fields=dict({ + "scope": "one", + "category_id": category.id, + "value_id": val.value_id, + "field": ann.field, + "then": "recheck", + }, **selection_provenance)) }} + {% endif %} + {% endfor %} +{% endmacro %} diff --git a/backend/tests/component/test_target_suggestions.py b/backend/tests/component/test_target_suggestions.py new file mode 100644 index 000000000..293fd5fd5 --- /dev/null +++ b/backend/tests/component/test_target_suggestions.py @@ -0,0 +1,577 @@ +# ABOUTME: Component tests for accepting the algorithm's recommended target changes (sc-281) +# ABOUTME: Covers the accept-suggestions route (one/all, comment, re-run gating) and the failure-view link + +import uuid +from datetime import UTC, datetime, timedelta +from unittest.mock import patch + +import pytest + +from opendlp.adapters import database +from opendlp.domain.assembly import SelectionRunRecord +from opendlp.domain.value_objects import SelectionRunStatus, SelectionTaskType +from opendlp.service_layer.assembly_service import create_assembly, update_csv_config +from opendlp.service_layer.target_checking import DetailedCheckResult, TargetAnnotation +from opendlp.service_layer.target_csv_import import import_targets_from_csv +from opendlp.service_layer.target_service import get_targets_for_assembly +from tests.fakes import FakeUnitOfWork + +CHECK = "opendlp.entrypoints.blueprints.targets.check_targets_detailed" +START = "opendlp.entrypoints.blueprints.targets.start_db_select_task" + +# Gender Male starts manual [5, 7]; the stubbed check suggests lowering the minimum to 3. +TARGETS_CSV = "feature,value,min,max\nGender,Male,5,7\nGender,Female,2,4\n" + + +@pytest.fixture(autouse=True) +def _mapped_domain_objects(): + """update_target_value writes via SQLAlchemy flag_modified, which needs mapped classes.""" + database.start_mappers() + + +@pytest.fixture +def assembly_with_targets(fake_store, admin_user): + with FakeUnitOfWork(store=fake_store) as uow: + assembly = create_assembly( + uow=uow, + title="Targets Assembly", + created_by_user_id=admin_user.id, + question="What should we select?", + first_assembly_date=(datetime.now(UTC).date() + timedelta(days=30)), + number_to_select=10, + ) + assembly_id = assembly.id + with FakeUnitOfWork(store=fake_store) as uow: + update_csv_config(uow=uow, user_id=admin_user.id, assembly_id=assembly_id, settings_confirmed=True) + with FakeUnitOfWork(store=fake_store) as uow: + import_targets_from_csv(uow=uow, user_id=admin_user.id, assembly_id=assembly_id, csv_content=TARGETS_CSV) + return assembly_id + + +def _ids(fake_store, admin_user, assembly_id, cat_name, val_name): + with FakeUnitOfWork(store=fake_store) as uow: + categories = get_targets_for_assembly(uow, admin_user.id, assembly_id) + category = next(c for c in categories if c.name == cat_name) + value = next(v for v in category.values if v.value == val_name) + return category.id, value + + +def _value(fake_store, admin_user, assembly_id, cat_name, val_name): + _cid, value = _ids(fake_store, admin_user, assembly_id, cat_name, val_name) + return value + + +def _min_suggestion(suggested): + return TargetAnnotation( + level="suggestion", + field="min", + message=f"Suggested minimum: {suggested}", + suggested_value=suggested, + ) + + +def _accept_url(assembly_id): + return f"/backoffice/assembly/{assembly_id}/targets/accept-suggestions" + + +class TestAcceptOne: + def test_applies_the_suggested_value_and_the_edited_comment( + self, logged_in_admin, admin_user, fake_store, assembly_with_targets + ): + assembly_id = assembly_with_targets + category_id, value = _ids(fake_store, admin_user, assembly_id, "Gender", "Male") + check = DetailedCheckResult( + success=False, global_errors=[], annotations={"Gender": {"Male": [_min_suggestion(3)]}} + ) + + with patch(CHECK, return_value=check): + response = logged_in_admin.post( + _accept_url(assembly_id), + data={ + "scope": "one", + "category_id": str(category_id), + "value_id": str(value.value_id), + "field": "min", + "comment": "Accepted algorithm suggestion — minimum 5 → 3 — checked with team", + "then": "recheck", + "from": "selection", + "run_id": str(uuid.uuid4()), + }, + follow_redirects=False, + ) + + assert response.status_code == 302 + assert "/targets/check" in response.headers["Location"] + assert "from=selection" in response.headers["Location"] + + applied = _value(fake_store, admin_user, assembly_id, "Gender", "Male") + assert applied.min == 3 + assert applied.max == 7 + assert applied.minmax_manual is True + assert applied.comment == "Accepted algorithm suggestion — minimum 5 → 3 — checked with team" + + def test_falls_back_to_an_auto_comment_when_none_is_supplied( + self, logged_in_admin, admin_user, fake_store, assembly_with_targets + ): + assembly_id = assembly_with_targets + category_id, value = _ids(fake_store, admin_user, assembly_id, "Gender", "Male") + check = DetailedCheckResult( + success=False, global_errors=[], annotations={"Gender": {"Male": [_min_suggestion(3)]}} + ) + + with patch(CHECK, return_value=check): + logged_in_admin.post( + _accept_url(assembly_id), + data={ + "scope": "one", + "category_id": str(category_id), + "value_id": str(value.value_id), + "field": "min", + "comment": "", + "then": "recheck", + }, + follow_redirects=False, + ) + + applied = _value(fake_store, admin_user, assembly_id, "Gender", "Male") + assert applied.min == 3 + assert "Accepted algorithm suggestion" in applied.comment + + def test_a_suggestion_the_check_no_longer_offers_changes_nothing( + self, logged_in_admin, admin_user, fake_store, assembly_with_targets + ): + assembly_id = assembly_with_targets + category_id, value = _ids(fake_store, admin_user, assembly_id, "Gender", "Male") + empty = DetailedCheckResult(success=True, global_errors=[], annotations={}) + + with patch(CHECK, return_value=empty): + response = logged_in_admin.post( + _accept_url(assembly_id), + data={ + "scope": "one", + "category_id": str(category_id), + "value_id": str(value.value_id), + "field": "min", + "then": "recheck", + }, + follow_redirects=False, + ) + + assert response.status_code == 302 + with logged_in_admin.session_transaction() as session: + flashes = [msg[1] for msg in session.get("_flashes", [])] + assert any("no longer available" in msg for msg in flashes) + + unchanged = _value(fake_store, admin_user, assembly_id, "Gender", "Male") + assert unchanged.min == 5 + + +class TestAcceptAll: + def test_applies_every_outstanding_suggestion(self, logged_in_admin, admin_user, fake_store, assembly_with_targets): + assembly_id = assembly_with_targets + check = DetailedCheckResult( + success=False, + global_errors=[], + annotations={ + "Gender": { + "Male": [_min_suggestion(3)], + "Female": [ + TargetAnnotation( + level="suggestion", field="max", message="Suggested maximum: 6", suggested_value=6 + ) + ], + } + }, + ) + + with patch(CHECK, return_value=check): + response = logged_in_admin.post( + _accept_url(assembly_id), + data={"scope": "all", "then": "recheck"}, + follow_redirects=False, + ) + + assert response.status_code == 302 + male = _value(fake_store, admin_user, assembly_id, "Gender", "Male") + female = _value(fake_store, admin_user, assembly_id, "Gender", "Female") + assert male.min == 3 + assert female.max == 6 + assert "Accepted algorithm suggestion" in male.comment + assert "Accepted algorithm suggestion" in female.comment + + +class TestReRunGating: + def test_accept_all_and_rerun_starts_a_selection_when_from_a_run( + self, logged_in_admin, admin_user, fake_store, assembly_with_targets + ): + assembly_id = assembly_with_targets + task_id = uuid.uuid4() + check = DetailedCheckResult( + success=False, global_errors=[], annotations={"Gender": {"Male": [_min_suggestion(3)]}} + ) + + with patch(CHECK, return_value=check), patch(START, return_value=task_id) as start: + response = logged_in_admin.post( + _accept_url(assembly_id), + data={"scope": "all", "then": "run", "from": "selection", "run_id": str(uuid.uuid4())}, + follow_redirects=False, + ) + + assert start.called + assert response.status_code == 302 + assert f"current_selection={task_id}" in response.headers["Location"] + + def test_accept_all_and_rerun_starts_a_real_selection_for_an_initial_run( + self, logged_in_admin, admin_user, fake_store, assembly_with_targets + ): + check = DetailedCheckResult( + success=False, global_errors=[], annotations={"Gender": {"Male": [_min_suggestion(3)]}} + ) + + with patch(CHECK, return_value=check), patch(START, return_value=uuid.uuid4()) as start: + logged_in_admin.post( + _accept_url(assembly_with_targets), + data={"scope": "all", "then": "run", "from": "selection", "run_id": str(uuid.uuid4())}, + ) + + assert start.call_args.kwargs["test_selection"] is False + + def test_a_failed_test_selection_is_rerun_as_a_test_selection( + self, logged_in_admin, admin_user, fake_store, assembly_with_targets + ): + check = DetailedCheckResult( + success=False, global_errors=[], annotations={"Gender": {"Male": [_min_suggestion(3)]}} + ) + + with patch(CHECK, return_value=check), patch(START, return_value=uuid.uuid4()) as start: + logged_in_admin.post( + _accept_url(assembly_with_targets), + data={"scope": "all", "then": "run", "from": "selection", "run_id": str(uuid.uuid4()), "test": "1"}, + ) + + assert start.call_args.kwargs["test_selection"] is True + + def test_recheck_after_accepting_keeps_the_test_flag( + self, logged_in_admin, admin_user, fake_store, assembly_with_targets + ): + check = DetailedCheckResult( + success=False, global_errors=[], annotations={"Gender": {"Male": [_min_suggestion(3)]}} + ) + + with patch(CHECK, return_value=check): + response = logged_in_admin.post( + _accept_url(assembly_with_targets), + data={"scope": "all", "then": "recheck", "from": "selection", "run_id": "r", "test": "1"}, + ) + + assert "test=1" in response.headers["Location"] + + def test_run_is_ignored_without_the_selection_provenance( + self, logged_in_admin, admin_user, fake_store, assembly_with_targets + ): + assembly_id = assembly_with_targets + check = DetailedCheckResult( + success=False, global_errors=[], annotations={"Gender": {"Male": [_min_suggestion(3)]}} + ) + + with patch(CHECK, return_value=check), patch(START) as start: + response = logged_in_admin.post( + _accept_url(assembly_id), + data={"scope": "all", "then": "run"}, # no from=selection + follow_redirects=False, + ) + + assert not start.called + assert "/targets/check" in response.headers["Location"] + + +def _flashes(client): + with client.session_transaction() as session: + return [msg[1] for msg in session.get("_flashes", [])] + + +class TestAcceptFailures: + """What accept-suggestions does when the form or the assembly is not what it expects.""" + + @pytest.mark.parametrize( + "form", + [ + {"category_id": "not-a-uuid", "value_id": "also-not"}, + {"category_id": str(uuid.uuid4()), "value_id": str(uuid.uuid4())}, + ], + ids=["malformed ids", "unknown target"], + ) + def test_a_target_that_cannot_be_found_changes_nothing( + self, logged_in_admin, admin_user, fake_store, assembly_with_targets, form + ): + check = DetailedCheckResult( + success=False, global_errors=[], annotations={"Gender": {"Male": [_min_suggestion(3)]}} + ) + + with patch(CHECK, return_value=check): + response = logged_in_admin.post( + _accept_url(assembly_with_targets), + data={"scope": "one", "field": "min", "then": "recheck", **form}, + ) + + assert "/targets/check" in response.headers["Location"] + assert any("no longer available" in msg for msg in _flashes(logged_in_admin)) + assert _value(fake_store, admin_user, assembly_with_targets, "Gender", "Male").min == 5 + + def test_rerun_waits_for_unconfirmed_selection_settings( + self, logged_in_admin, admin_user, fake_store, assembly_with_targets + ): + with FakeUnitOfWork(store=fake_store) as uow: + update_csv_config( + uow=uow, user_id=admin_user.id, assembly_id=assembly_with_targets, settings_confirmed=False + ) + check = DetailedCheckResult( + success=False, global_errors=[], annotations={"Gender": {"Male": [_min_suggestion(3)]}} + ) + + with patch(CHECK, return_value=check), patch(START) as start: + response = logged_in_admin.post( + _accept_url(assembly_with_targets), + data={"scope": "all", "then": "run", "from": "selection", "run_id": "r"}, + ) + + assert not start.called + assert "/targets/check" in response.headers["Location"] + assert any("review and save the selection settings" in msg for msg in _flashes(logged_in_admin)) + # The accepted change is still saved; only the re-run waits. + assert _value(fake_store, admin_user, assembly_with_targets, "Gender", "Male").min == 3 + + def test_an_unknown_assembly_goes_back_to_the_dashboard(self, logged_in_admin): + response = logged_in_admin.post(_accept_url(uuid.uuid4()), data={"scope": "all", "then": "recheck"}) + + assert response.headers["Location"].endswith("/dashboard") + assert "Assembly not found" in _flashes(logged_in_admin) + + def test_someone_who_may_not_manage_the_assembly_is_turned_away( + self, logged_in_user, admin_user, fake_store, assembly_with_targets + ): + with patch(CHECK) as check: + response = logged_in_user.post(_accept_url(assembly_with_targets), data={"scope": "all", "then": "recheck"}) + + assert not check.called + assert response.headers["Location"].endswith("/dashboard") + assert any("permission" in msg for msg in _flashes(logged_in_user)) + assert _value(fake_store, admin_user, assembly_with_targets, "Gender", "Male").min == 5 + + def test_an_unexpected_error_goes_back_to_the_targets_page(self, logged_in_admin, assembly_with_targets): + with patch(CHECK, side_effect=RuntimeError("boom")): + response = logged_in_admin.post( + _accept_url(assembly_with_targets), data={"scope": "all", "then": "recheck"} + ) + + assert response.headers["Location"].endswith(f"/backoffice/assembly/{assembly_with_targets}/targets") + assert any("unexpected error" in msg for msg in _flashes(logged_in_admin)) + + +class TestCheckPageControls: + def _check_url(self, assembly_id, query=""): + return f"/backoffice/assembly/{assembly_id}/targets/check{query}" + + def test_renders_inline_accept_editor_and_rerun_when_from_a_run( + self, logged_in_admin, fake_store, assembly_with_targets + ): + check = DetailedCheckResult( + success=False, global_errors=[], annotations={"Gender": {"Male": [_min_suggestion(3)]}} + ) + with patch(CHECK, return_value=check): + response = logged_in_admin.get( + self._check_url(assembly_with_targets, f"?from=selection&run_id={uuid.uuid4()}") + ) + + assert response.status_code == 200 + assert b"accept-comment-" in response.data # inline, editable comment field + assert b"Accept all suggestions" in response.data + assert b"re-run selection" in response.data + + def test_direct_visit_omits_the_rerun_button(self, logged_in_admin, fake_store, assembly_with_targets): + check = DetailedCheckResult( + success=False, global_errors=[], annotations={"Gender": {"Male": [_min_suggestion(3)]}} + ) + with patch(CHECK, return_value=check): + response = logged_in_admin.get(self._check_url(assembly_with_targets)) + + assert response.status_code == 200 + assert b"Accept all suggestions" in response.data + assert b"re-run selection" not in response.data + + +class TestEditModeEntry: + """ "Adjust targets" opens the editor, each suggestion under the row it would change.""" + + def _check_url(self, assembly_id, query=""): + return f"/backoffice/assembly/{assembly_id}/targets/check{query}" + + def _get(self, client, assembly_id, query): + check = DetailedCheckResult( + success=False, global_errors=[], annotations={"Gender": {"Male": [_min_suggestion(3)]}} + ) + with patch(CHECK, return_value=check): + return client.get(self._check_url(assembly_id, query)) + + def test_edit_flag_opens_the_editor_with_suggestions_filling_the_row( + self, logged_in_admin, admin_user, fake_store, assembly_with_targets + ): + category_id, male = _ids(fake_store, admin_user, assembly_with_targets, "Gender", "Male") + response = self._get(logged_in_admin, assembly_with_targets, f"?from=selection&run_id={uuid.uuid4()}&edit=1") + + html = response.data.decode() + assert response.status_code == 200 + assert "editingAll: true" in html + # The edit-form suggestion fills the row's own min and comment inputs in. + prefix = f"bulk-{category_id}-{male.value_id}" + assert f""{prefix}-min": 3" in html or f'"{prefix}-min": 3' in html + assert f'data-comment-target="{prefix}-comment"' in html + assert "Save & re-run selection" in html + + def test_without_the_edit_flag_the_page_opens_read_only(self, logged_in_admin, fake_store, assembly_with_targets): + response = self._get(logged_in_admin, assembly_with_targets, f"?from=selection&run_id={uuid.uuid4()}") + + assert "editingAll: false" in response.data.decode() + + def test_a_test_run_is_offered_a_test_rerun_and_carries_the_flag( + self, logged_in_admin, fake_store, assembly_with_targets + ): + response = self._get(logged_in_admin, assembly_with_targets, "?from=selection&run_id=r&test=1&edit=1") + + html = response.data.decode() + assert "Save & re-run test selection" in html + assert "Accept all & re-run test selection" in html + assert '' in html + + def test_save_and_rerun_is_only_offered_from_a_run(self, logged_in_admin, fake_store, assembly_with_targets): + response = self._get(logged_in_admin, assembly_with_targets, "?edit=1") + + assert b"Save & re-run selection" not in response.data + + +class TestSaveAllFromSelection: + def _post(self, client, fake_store, admin_user, assembly_id, extra): + category_id, male = _ids(fake_store, admin_user, assembly_id, "Gender", "Male") + prefix = f"cat[{category_id}]" + data = { + f"{prefix}[name]": "Gender", + f"{prefix}[values][{male.value_id}][value]": "Male", + f"{prefix}[values][{male.value_id}][min]": "3", + f"{prefix}[values][{male.value_id}][max]": "7", + f"{prefix}[values][{male.value_id}][comment]": "Accepted algorithm suggestion — minimum 5 → 3", + **extra, + } + return client.post(f"/backoffice/assembly/{assembly_id}/targets/save-all", data=data) + + def test_save_and_rerun_saves_then_starts_a_selection( + self, logged_in_admin, admin_user, fake_store, assembly_with_targets + ): + task_id = uuid.uuid4() + with patch(START, return_value=task_id) as start: + response = self._post( + logged_in_admin, + fake_store, + admin_user, + assembly_with_targets, + {"from": "selection", "run_id": str(uuid.uuid4()), "then": "run"}, + ) + + assert start.called + assert f"current_selection={task_id}" in response.headers["Location"] + assert _value(fake_store, admin_user, assembly_with_targets, "Gender", "Male").min == 3 + + def test_save_and_rerun_after_a_test_run_starts_a_test_selection( + self, logged_in_admin, admin_user, fake_store, assembly_with_targets + ): + with patch(START, return_value=uuid.uuid4()) as start: + self._post( + logged_in_admin, + fake_store, + admin_user, + assembly_with_targets, + {"from": "selection", "run_id": str(uuid.uuid4()), "then": "run", "test": "1"}, + ) + + assert start.call_args.kwargs["test_selection"] is True + + def test_plain_save_from_a_run_keeps_the_provenance( + self, logged_in_admin, admin_user, fake_store, assembly_with_targets + ): + run_id = str(uuid.uuid4()) + with patch(START) as start: + response = self._post( + logged_in_admin, fake_store, admin_user, assembly_with_targets, {"from": "selection", "run_id": run_id} + ) + + assert not start.called + location = response.headers["Location"] + assert "/targets/check" in location + assert "from=selection" in location + assert run_id in location + + def test_run_is_ignored_without_the_selection_provenance( + self, logged_in_admin, admin_user, fake_store, assembly_with_targets + ): + with patch(START) as start: + response = self._post(logged_in_admin, fake_store, admin_user, assembly_with_targets, {"then": "run"}) + + assert not start.called + assert "/targets/check" in response.headers["Location"] + + +def _add_failed_run(fake_store, assembly_id, task_type): + with FakeUnitOfWork(store=fake_store) as uow: + uow.selection_run_records.add( + SelectionRunRecord( + assembly_id=assembly_id, + task_id=uuid.uuid4(), + status=SelectionRunStatus.FAILED, + task_type=task_type, + log_messages=["Task failed"], + error_message="The targets cannot all be met from the pool.", + completed_at=datetime.now(UTC), + ) + ) + uow.commit() + return uow.selection_run_records.get_by_assembly_id(assembly_id)[-1].task_id + + +class TestFailureViewLink: + def _modal(self, client, assembly_id, run_id): + return client.get(f"/backoffice/assembly/{assembly_id}/selection/db/modal-progress/{run_id}") + + def test_failed_initial_selection_offers_adjust_targets(self, logged_in_admin, fake_store, assembly_with_targets): + run_id = _add_failed_run(fake_store, assembly_with_targets, SelectionTaskType.SELECT_FROM_DB) + response = self._modal(logged_in_admin, assembly_with_targets, run_id) + + assert response.status_code == 200 + assert b"Adjust targets" in response.data + assert b"from=selection" in response.data + assert b"edit=1" in response.data + + def test_failed_test_selection_offers_adjust_targets_marked_as_a_test( + self, logged_in_admin, fake_store, assembly_with_targets + ): + run_id = _add_failed_run(fake_store, assembly_with_targets, SelectionTaskType.TEST_SELECT_FROM_DB) + response = self._modal(logged_in_admin, assembly_with_targets, run_id) + + assert response.status_code == 200 + assert b"Adjust targets" in response.data + assert b"test=1" in response.data + + def test_failed_initial_selection_link_is_not_marked_as_a_test( + self, logged_in_admin, fake_store, assembly_with_targets + ): + run_id = _add_failed_run(fake_store, assembly_with_targets, SelectionTaskType.SELECT_FROM_DB) + response = self._modal(logged_in_admin, assembly_with_targets, run_id) + + assert b"test=1" not in response.data + + def test_failed_replacement_run_has_no_adjust_targets_link( + self, logged_in_admin, fake_store, assembly_with_targets + ): + run_id = _add_failed_run(fake_store, assembly_with_targets, SelectionTaskType.SELECT_REPLACEMENT_FROM_DB) + response = self._modal(logged_in_admin, assembly_with_targets, run_id) + + assert response.status_code == 200 + assert b"Adjust targets" not in response.data diff --git a/backend/translations/hu/LC_MESSAGES/messages.po b/backend/translations/hu/LC_MESSAGES/messages.po index ef82ac506..92a743a40 100644 --- a/backend/translations/hu/LC_MESSAGES/messages.po +++ b/backend/translations/hu/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-19 16:31+0100\n" +"POT-Creation-Date: 2026-10-20 12:53+0200\n" "PO-Revision-Date: 2026-09-16 14:15+0000\n" "Last-Translator: Hamish Downer \n" "Language: hu\n" @@ -73,8 +73,8 @@ msgstr "Feldolgozás…" #: templates/backoffice/assembly_respondents.html:71 #: templates/backoffice/components/assembly_tabs.html:58 #: templates/backoffice/respondents/export_modal.html:64 -#: templates/backoffice/targets/bulk_edit_form.html:259 -#: templates/backoffice/targets/category_block.html:127 +#: templates/backoffice/targets/bulk_edit_form.html:260 +#: templates/backoffice/targets/category_block.html:128 #: templates/db_selection/select.html:43 #: templates/main/view_assembly_base.html:42 #: templates/respondents/view_respondents.html:2 @@ -559,8 +559,8 @@ msgstr "Jelentkezők" #: templates/backoffice/assembly_dashboard.html:114 #: templates/backoffice/assembly_view_respondent.html:226 #: templates/backoffice/components/table.html:209 -#: templates/backoffice/targets/bulk_edit_form.html:268 -#: templates/backoffice/targets/category_block.html:136 +#: templates/backoffice/targets/bulk_edit_form.html:269 +#: templates/backoffice/targets/category_block.html:137 #: templates/respondents/view_respondents.html:132 msgid "Selected" msgstr "Kiválasztva" @@ -1115,7 +1115,7 @@ msgid "Select a CSV file containing targets" msgstr "Válassz ki egy kiválasztási szempontokat tartalmazó CSV fájlt" #: src/opendlp/entrypoints/forms.py:655 src/opendlp/entrypoints/forms.py:665 -#: templates/backoffice/targets/bulk_edit_form.html:213 +#: templates/backoffice/targets/bulk_edit_form.html:214 msgid "Target Name" msgstr "Kiválasztási szempont neve" @@ -1148,8 +1148,8 @@ msgstr "Nem kötelező – link arra, hogy honnan származnak a százalékos ar #: templates/backoffice/components/db_replacement_modal.html:129 #: templates/backoffice/components/db_selection_progress_modal.html:84 #: templates/backoffice/components/db_selection_progress_modal.html:92 -#: templates/backoffice/targets/bulk_edit_form.html:252 -#: templates/backoffice/targets/category_block.html:120 +#: templates/backoffice/targets/bulk_edit_form.html:253 +#: templates/backoffice/targets/category_block.html:121 #: templates/targets/components/category_block.html:69 #: templates/targets/components/category_block.html:136 #: templates/targets/components/category_block.html:263 @@ -1171,8 +1171,8 @@ msgstr "Nem kötelező – a minimum és a maximum ebből adódik" #: src/opendlp/entrypoints/forms.py:699 #: src/opendlp/service_layer/selection_report.py:221 #: templates/backoffice/components/db_selection_progress_modal.html:93 -#: templates/backoffice/targets/bulk_edit_form.html:274 -#: templates/backoffice/targets/category_block.html:142 +#: templates/backoffice/targets/bulk_edit_form.html:275 +#: templates/backoffice/targets/category_block.html:143 #: templates/targets/components/category_block.html:70 #: templates/targets/components/category_block.html:147 #: templates/targets/components/category_block.html:275 @@ -1182,8 +1182,8 @@ msgstr "Min." #: src/opendlp/entrypoints/forms.py:704 #: src/opendlp/service_layer/selection_report.py:222 #: templates/backoffice/components/db_selection_progress_modal.html:94 -#: templates/backoffice/targets/bulk_edit_form.html:276 -#: templates/backoffice/targets/category_block.html:144 +#: templates/backoffice/targets/bulk_edit_form.html:277 +#: templates/backoffice/targets/category_block.html:145 #: templates/targets/components/category_block.html:71 #: templates/targets/components/category_block.html:158 #: templates/targets/components/category_block.html:286 @@ -1713,8 +1713,8 @@ msgstr "Hiba történt a közösségi gyűlés létrehozásakor" #: src/opendlp/entrypoints/blueprints/target_sources.py:667 #: src/opendlp/entrypoints/blueprints/target_sources.py:689 #: src/opendlp/entrypoints/blueprints/target_sources.py:870 -#: src/opendlp/entrypoints/blueprints/targets.py:154 -#: src/opendlp/entrypoints/blueprints/targets.py:405 +#: src/opendlp/entrypoints/blueprints/targets.py:157 +#: src/opendlp/entrypoints/blueprints/targets.py:414 #: src/opendlp/entrypoints/blueprints/targets_legacy.py:115 #: src/opendlp/entrypoints/blueprints/targets_legacy.py:791 msgid "You don't have permission to view this assembly" @@ -1808,10 +1808,11 @@ msgstr "Nincs jogosultságod a közösségi gyűlés megtekintéséhez" #: src/opendlp/entrypoints/blueprints/target_sources.py:924 #: src/opendlp/entrypoints/blueprints/target_sources.py:988 #: src/opendlp/entrypoints/blueprints/target_sources.py:1004 -#: src/opendlp/entrypoints/blueprints/targets.py:145 -#: src/opendlp/entrypoints/blueprints/targets.py:219 -#: src/opendlp/entrypoints/blueprints/targets.py:274 -#: src/opendlp/entrypoints/blueprints/targets.py:402 +#: src/opendlp/entrypoints/blueprints/targets.py:148 +#: src/opendlp/entrypoints/blueprints/targets.py:222 +#: src/opendlp/entrypoints/blueprints/targets.py:277 +#: src/opendlp/entrypoints/blueprints/targets.py:411 +#: src/opendlp/entrypoints/blueprints/targets.py:603 #: src/opendlp/entrypoints/blueprints/targets_legacy.py:112 #: src/opendlp/entrypoints/blueprints/targets_legacy.py:182 #: src/opendlp/entrypoints/blueprints/targets_legacy.py:788 @@ -2266,6 +2267,7 @@ msgstr "Váratlan hiba történt az adatok ellenőrzése közben" #: src/opendlp/entrypoints/blueprints/db_selection_backoffice.py:114 #: src/opendlp/entrypoints/blueprints/db_selection_backoffice.py:161 +#: src/opendlp/entrypoints/blueprints/targets.py:534 msgid "Please review and save the selection settings before running selection" msgstr "" "A kiválasztás futtatása előtt nézd át és mentsd el a kiválasztási " @@ -2298,6 +2300,7 @@ msgstr "A kiválasztás futtatásának indítása sikertelen: %(error)s" #: src/opendlp/entrypoints/blueprints/gsheets_legacy.py:945 #: src/opendlp/entrypoints/blueprints/gsheets_legacy.py:1252 #: src/opendlp/entrypoints/blueprints/gsheets_legacy.py:1295 +#: src/opendlp/entrypoints/blueprints/targets.py:606 msgid "You don't have permission to manage this assembly" msgstr "Nincs jogosultságod a közösségi gyűlés kezeléséhez" @@ -3004,7 +3007,7 @@ msgstr "" #: src/opendlp/entrypoints/blueprints/respondents.py:200 #: src/opendlp/entrypoints/blueprints/respondents_legacy.py:176 -#: src/opendlp/entrypoints/blueprints/targets.py:225 +#: src/opendlp/entrypoints/blueprints/targets.py:228 #: src/opendlp/entrypoints/blueprints/targets_legacy.py:188 msgid "Could not read CSV file. Please ensure it is UTF-8 encoded." msgstr "Nem sikerült beolvasni a CSV fájlt. Győződj meg róla, hogy UTF-8 kódolású." @@ -3170,7 +3173,7 @@ msgid "%(count)s rows were skipped: %(errors)s" msgstr "%(count)s sor kihagyva: %(errors)s" #: src/opendlp/entrypoints/blueprints/respondents_legacy.py:167 -#: src/opendlp/entrypoints/blueprints/targets.py:216 +#: src/opendlp/entrypoints/blueprints/targets.py:219 #: src/opendlp/entrypoints/blueprints/targets_legacy.py:179 #, python-format msgid "CSV import failed: %(error)s" @@ -3181,7 +3184,7 @@ msgid "You don't have permission to import respondents" msgstr "Nincs jogosultságod jelentkezők importálásához" #: src/opendlp/entrypoints/blueprints/respondents_legacy.py:180 -#: src/opendlp/entrypoints/blueprints/targets.py:229 +#: src/opendlp/entrypoints/blueprints/targets.py:232 #: src/opendlp/entrypoints/blueprints/targets_legacy.py:192 msgid "An unexpected error occurred during import" msgstr "Váratlan hiba történt az importálás közben" @@ -3374,11 +3377,11 @@ msgstr "Számított kérdés törölve" msgid "Question unlinked from its target" msgstr "A kérdés leválasztva a kiválasztási szempontról" -#: src/opendlp/entrypoints/blueprints/targets.py:160 +#: src/opendlp/entrypoints/blueprints/targets.py:163 msgid "An error occurred while loading assembly targets" msgstr "Hiba történt a közösségi gyűlés célszámainak betöltése közben" -#: src/opendlp/entrypoints/blueprints/targets.py:202 +#: src/opendlp/entrypoints/blueprints/targets.py:205 #: src/opendlp/entrypoints/blueprints/targets_legacy.py:167 #, python-format msgid "Successfully imported %(cats)s targets with %(vals)s values from %(file)s" @@ -3386,64 +3389,96 @@ msgstr "" "Sikeresen importálva %(cats)s kiválasztási szempont %(vals)s értékkel a " "következő fájlból: %(file)s" -#: src/opendlp/entrypoints/blueprints/targets.py:222 +#: src/opendlp/entrypoints/blueprints/targets.py:225 #: src/opendlp/entrypoints/blueprints/targets_legacy.py:185 msgid "You don't have permission to import targets" msgstr "Nincs jogosultságod célszámok importálásához" -#: src/opendlp/entrypoints/blueprints/targets.py:247 +#: src/opendlp/entrypoints/blueprints/targets.py:250 #, python-format msgid "Targets deleted: %(count)d" msgstr "Kiválasztási szempontok törölve: %(count)d" -#: src/opendlp/entrypoints/blueprints/targets.py:268 +#: src/opendlp/entrypoints/blueprints/targets.py:271 msgid "You don't have permission to delete targets" msgstr "Nincs jogosultságod célszámok törléséhez" -#: src/opendlp/entrypoints/blueprints/targets.py:280 +#: src/opendlp/entrypoints/blueprints/targets.py:283 msgid "An error occurred while deleting targets" msgstr "Hiba történt a célszámok törlése közben" -#: src/opendlp/entrypoints/blueprints/targets.py:296 +#: src/opendlp/entrypoints/blueprints/targets.py:299 #: src/opendlp/entrypoints/blueprints/targets_legacy.py:663 msgid "No columns selected" msgstr "Nincs kiválasztott oszlop" -#: src/opendlp/entrypoints/blueprints/targets.py:338 +#: src/opendlp/entrypoints/blueprints/targets.py:341 #: src/opendlp/entrypoints/blueprints/targets_legacy.py:731 msgid "No new targets were created" msgstr "Nem jött létre új kiválasztási szempont" -#: src/opendlp/entrypoints/blueprints/targets.py:344 +#: src/opendlp/entrypoints/blueprints/targets.py:347 #: src/opendlp/entrypoints/blueprints/targets_legacy.py:718 #, python-format msgid "Created %(count)s targets with %(values)s values: %(names)s" msgstr "%(count)s kiválasztási szempont létrehozva %(values)s értékkel: %(names)s" -#: src/opendlp/entrypoints/blueprints/targets.py:353 +#: src/opendlp/entrypoints/blueprints/targets.py:356 #: src/opendlp/entrypoints/blueprints/targets_legacy.py:727 #, python-format msgid "Created %(count)s targets: %(names)s" msgstr "%(count)s kiválasztási szempont létrehozva: %(names)s" -#: src/opendlp/entrypoints/blueprints/targets.py:409 +#: src/opendlp/entrypoints/blueprints/targets.py:418 #: src/opendlp/entrypoints/blueprints/targets_legacy.py:795 msgid "An unexpected error occurred while checking targets" msgstr "Váratlan hiba történt a célszámok ellenőrzése közben" -#: src/opendlp/entrypoints/blueprints/targets.py:433 +#: src/opendlp/entrypoints/blueprints/targets.py:425 +#, python-format +msgid "minimum %(current)s → %(suggested)s" +msgstr "" + +#: src/opendlp/entrypoints/blueprints/targets.py:426 +#, python-format +msgid "maximum %(current)s → %(suggested)s" +msgstr "" + +#: src/opendlp/entrypoints/blueprints/targets.py:493 +#, python-format +msgid "Accepted algorithm suggestion — %(change)s" +msgstr "" + +#: src/opendlp/entrypoints/blueprints/targets.py:517 +#, python-format +msgid "Accepted algorithm suggestion — %(changes)s" +msgstr "" + +#: src/opendlp/entrypoints/blueprints/targets.py:593 +msgid "That suggestion is no longer available. The targets have been re-checked." +msgstr "" + +#: src/opendlp/entrypoints/blueprints/targets.py:599 +msgid "Recommended changes applied." +msgstr "" + +#: src/opendlp/entrypoints/blueprints/targets.py:610 +msgid "An unexpected error occurred while applying the recommended changes" +msgstr "" + +#: src/opendlp/entrypoints/blueprints/targets.py:634 msgid "Your targets were not saved. Please correct the errors below." msgstr "A célszámaid nem lettek elmentve. Javítsd az alábbi hibákat." -#: src/opendlp/entrypoints/blueprints/targets.py:487 +#: src/opendlp/entrypoints/blueprints/targets.py:689 msgid "Please try again" msgstr "Kérjük, próbáld újra" -#: src/opendlp/entrypoints/blueprints/targets.py:505 +#: src/opendlp/entrypoints/blueprints/targets.py:712 msgid "Targets saved" msgstr "A célszámok mentve" -#: src/opendlp/entrypoints/blueprints/targets.py:519 +#: src/opendlp/entrypoints/blueprints/targets.py:728 #: src/opendlp/entrypoints/blueprints/targets_legacy.py:253 #: src/opendlp/entrypoints/blueprints/targets_legacy.py:335 #: src/opendlp/entrypoints/blueprints/targets_legacy.py:457 @@ -3452,11 +3487,11 @@ msgstr "A célszámok mentve" msgid "Error: %(error)s" msgstr "Hiba: %(error)s" -#: src/opendlp/entrypoints/blueprints/targets.py:522 +#: src/opendlp/entrypoints/blueprints/targets.py:731 msgid "You don't have permission to edit these targets" msgstr "Nincs jogosultságod ezeknek a célszámoknak a szerkesztéséhez" -#: src/opendlp/entrypoints/blueprints/targets.py:530 +#: src/opendlp/entrypoints/blueprints/targets.py:739 msgid "An error occurred while saving the targets" msgstr "Hiba történt a célszámok mentése közben" @@ -4569,7 +4604,7 @@ msgstr "" "%(letter)s oszlop %(row)d. sorában null karakter van, amely nem menthető " "el. Távolítsd el, majd töltsd fel újra a fájlt." -#: src/opendlp/service_layer/target_checking.py:120 +#: src/opendlp/service_layer/target_checking.py:134 #, python-format msgid "" "Sum of max values (%(value)s) is the smallest across all targets — " @@ -4578,7 +4613,7 @@ msgstr "" "A maximumok összege (%(value)s) a legkisebb az összes kiválasztási " "szempont közül – ütközik '%(other)s' kiválasztási szemponttal" -#: src/opendlp/service_layer/target_checking.py:133 +#: src/opendlp/service_layer/target_checking.py:147 #, python-format msgid "" "Sum of min values (%(value)s) is the largest across all targets — " @@ -4587,38 +4622,38 @@ msgstr "" "A minimumok összege (%(value)s) a legnagyobb az összes kiválasztási " "szempont közül – ütközik '%(other)s' kiválasztási szemponttal" -#: src/opendlp/service_layer/target_checking.py:147 +#: src/opendlp/service_layer/target_checking.py:161 #, python-format msgid "Sum of minimums (%(sum)s) exceeds number to select (%(limit)s)" msgstr "" "A minimumok összege (%(sum)s) meghaladja a kiválasztandók számát " "(%(limit)s)" -#: src/opendlp/service_layer/target_checking.py:160 +#: src/opendlp/service_layer/target_checking.py:174 #, python-format msgid "Sum of maximums (%(sum)s) is less than number to select (%(limit)s)" msgstr "" "A maximumok összege (%(sum)s) kevesebb, mint a kiválasztandók száma " "(%(limit)s)" -#: src/opendlp/service_layer/target_checking.py:181 +#: src/opendlp/service_layer/target_checking.py:195 #, python-format msgid "Need minimum %(min)s but only %(count)s respondents match" msgstr "Legalább %(min)s szükséges, de csak %(count)s jelentkező felel meg" -#: src/opendlp/service_layer/target_checking.py:208 +#: src/opendlp/service_layer/target_checking.py:222 #: templates/backoffice/components/db_replacement_modal.html:197 #, python-format msgid "Suggested minimum: %(value)s (currently %(current)s)" msgstr "Javasolt minimum: %(value)s (jelenleg %(current)s)" -#: src/opendlp/service_layer/target_checking.py:224 +#: src/opendlp/service_layer/target_checking.py:238 #: templates/backoffice/components/db_replacement_modal.html:199 #, python-format msgid "Suggested maximum: %(value)s (currently %(current)s)" msgstr "Javasolt maximum: %(value)s (jelenleg %(current)s)" -#: src/opendlp/service_layer/target_checking.py:275 +#: src/opendlp/service_layer/target_checking.py:289 #, python-format msgid "Percentages for this target add up to %(total)s%%, not 100%%" msgstr "" @@ -5114,7 +5149,7 @@ msgid "Unknown" msgstr "Ismeretlen" #: templates/admin/invite_view.html:105 templates/admin/user_edit.html:107 -#: templates/backoffice/targets/category_block.html:192 +#: templates/backoffice/targets/category_block.html:189 #: templates/db_selection/components/progress.html:91 #: templates/db_selection/select.html:24 templates/db_selection/select.html:54 #: templates/gsheets/components/progress.html:101 @@ -5203,7 +5238,7 @@ msgstr "Lejárat" #: templates/backoffice/components/db_selection_progress_modal.html:124 #: templates/backoffice/components/registration_page_list.html:38 #: templates/backoffice/respondent_field_schema/_editor.html:51 -#: templates/backoffice/targets/bulk_edit_form.html:280 +#: templates/backoffice/targets/bulk_edit_form.html:281 #: templates/main/view_assembly_data.html:60 #: templates/main/view_assembly_members.html:13 #: templates/targets/components/category_block.html:76 @@ -5306,7 +5341,7 @@ msgid "View details" msgstr "Részletek megtekintése" #: templates/admin/user_2fa_audit_log.html:82 -#: templates/backoffice/targets/bulk_edit_form.html:176 +#: templates/backoffice/targets/bulk_edit_form.html:177 #: templates/main/view_assembly_data.html:105 msgid "None" msgstr "Nincs" @@ -6078,7 +6113,7 @@ msgstr "Tölts fel CSV fájlokat a célszámok és az emberek adatainak importá #: templates/backoffice/assembly_data.html:312 #: templates/backoffice/assembly_targets.html:13 -#: templates/backoffice/assembly_targets.html:84 +#: templates/backoffice/assembly_targets.html:96 #: templates/backoffice/components/assembly_tabs.html:45 #: templates/main/view_assembly_base.html:36 #: templates/targets/view_targets.html:2 @@ -6608,9 +6643,9 @@ msgstr "Befejezve" #: templates/backoffice/assembly_selection.html:104 #: templates/backoffice/assembly_selection.html:231 #: templates/backoffice/assembly_view_respondent.html:242 -#: templates/backoffice/targets/bulk_edit_form.html:227 -#: templates/backoffice/targets/bulk_edit_form.html:278 -#: templates/backoffice/targets/category_block.html:146 +#: templates/backoffice/targets/bulk_edit_form.html:228 +#: templates/backoffice/targets/bulk_edit_form.html:279 +#: templates/backoffice/targets/category_block.html:147 #: templates/main/view_assembly_data.html:59 msgid "Comment" msgstr "Megjegyzés" @@ -6685,27 +6720,27 @@ msgstr "Kezdeti kiválasztás kiválasztási szempontok munkalapja:" msgid "Replacement Categories Tab:" msgstr "Pótláshoz tartozó kiválasztási szempontok munkalapja:" -#: templates/backoffice/assembly_targets.html:88 +#: templates/backoffice/assembly_targets.html:100 #, python-format msgid "Number to select: %(number)s" msgstr "Kiválasztandók száma: %(number)s" -#: templates/backoffice/assembly_targets.html:92 +#: templates/backoffice/assembly_targets.html:104 #: templates/targets/view_targets.html:8 msgid "No targets defined yet." msgstr "Még nincsenek meghatározva kiválasztási szempontok." -#: templates/backoffice/assembly_targets.html:105 +#: templates/backoffice/assembly_targets.html:117 #: templates/backoffice/targets/add_target_modal.html:14 #: templates/targets/components/add_category_form.html:28 msgid "Add target" msgstr "Célszám hozzáadása" -#: templates/backoffice/assembly_targets.html:111 +#: templates/backoffice/assembly_targets.html:123 msgid "Edit targets" msgstr "Célszámok szerkesztése" -#: templates/backoffice/assembly_targets.html:123 +#: templates/backoffice/assembly_targets.html:135 #, python-format msgid "" "All checks passed. Found %(num_features)s targets and %(num_people)s " @@ -6715,14 +6750,14 @@ msgstr "" "%(num_people)s jogosult jelentkező található. A célszámok készen állnak a" " kiválasztásra." -#: templates/backoffice/assembly_targets.html:132 +#: templates/backoffice/assembly_targets.html:144 #: templates/targets/view_targets.html:41 #: templates/targets/view_targets.html:52 #: templates/targets/view_targets.html:61 msgid "Target check found problems" msgstr "A célszámok ellenőrzése problémákat talált" -#: templates/backoffice/assembly_targets.html:140 +#: templates/backoffice/assembly_targets.html:152 msgid "" "Target check found problems. See details below next to the affected " "targets." @@ -6730,12 +6765,39 @@ msgstr "" "A célszámok ellenőrzése problémákat talált. A részleteket alább, az " "érintett célszámok mellett találod." -#: templates/backoffice/assembly_targets.html:181 +#: templates/backoffice/assembly_targets.html:170 +msgid "" +"The algorithm has recommended target changes. Accept them individually " +"below, or all at once here." +msgstr "" + +#: templates/backoffice/assembly_targets.html:173 +#: templates/backoffice/assembly_targets.html:184 +msgid "Accept all suggestions" +msgstr "" + +#: templates/backoffice/assembly_targets.html:193 +msgid "Accept all & re-run test selection" +msgstr "" + +#: templates/backoffice/assembly_targets.html:193 +msgid "Accept all & re-run selection" +msgstr "" + +#: templates/backoffice/assembly_targets.html:236 #: templates/backoffice/components/leave_guard_modal.html:17 msgid "Discard changes" msgstr "Módosítások elvetése" -#: templates/backoffice/assembly_targets.html:182 +#: templates/backoffice/assembly_targets.html:238 +msgid "Save & re-run test selection" +msgstr "" + +#: templates/backoffice/assembly_targets.html:238 +msgid "Save & re-run selection" +msgstr "" + +#: templates/backoffice/assembly_targets.html:240 #: templates/backoffice/components/edit_number_to_select_modal.html:41 #: templates/backoffice/registration/_step_email.html:95 #: templates/backoffice/registration/_step_form.html:23 @@ -7195,6 +7257,7 @@ msgstr "%(target)s: %(value)s, maximum %(current)s helyett %(suggested)s" #: templates/backoffice/components/db_replacement_modal.html:90 #: templates/backoffice/components/db_replacement_modal.html:201 +#: templates/backoffice/components/suggestion_row.html:52 msgid "Accept" msgstr "Elfogadás" @@ -7402,6 +7465,10 @@ msgstr "Feladat megszakítása" msgid "Put Task in Background" msgstr "Feladat háttérbe helyezése" +#: templates/backoffice/components/db_selection_progress_modal.html:197 +msgid "Adjust targets" +msgstr "" + #: templates/backoffice/components/edit_number_to_select_modal.html:11 msgid "Edit Number to Select" msgstr "Kiválasztandók számának szerkesztése" @@ -7916,8 +7983,9 @@ msgid "Delete table" msgstr "Táblázat törlése" #: templates/backoffice/components/rich_editor.html:149 -#: templates/backoffice/targets/bulk_edit_form.html:155 -#: templates/backoffice/targets/bulk_edit_form.html:201 +#: templates/backoffice/components/suggestion_row.html:103 +#: templates/backoffice/targets/bulk_edit_form.html:156 +#: templates/backoffice/targets/bulk_edit_form.html:202 msgid "Undo" msgstr "Mégse" @@ -8031,6 +8099,21 @@ msgstr "befejezve" msgid "has errors" msgstr "hibás" +#: templates/backoffice/components/suggestion_row.html:53 +msgid "Comment saved with this change" +msgstr "" + +#: templates/backoffice/components/suggestion_row.html:66 +#: templates/backoffice/targets/category_block.html:96 +#: templates/targets/components/category_block.html:59 +#: templates/targets/components/category_block.html:183 +msgid "Suggestion" +msgstr "Javaslat" + +#: templates/backoffice/components/suggestion_row.html:102 +msgid "Accepted. Save to keep the change." +msgstr "" + #: templates/backoffice/components/url_display.html:24 msgid "Copied to clipboard" msgstr "Vágólapra másolva" @@ -8953,12 +9036,12 @@ msgid "Edit %(question)s" msgstr "%(question)s szerkesztése" #: templates/backoffice/respondent_field_schema/_editor.html:100 -#: templates/backoffice/targets/bulk_edit_form.html:243 +#: templates/backoffice/targets/bulk_edit_form.html:244 msgid "Move up" msgstr "Mozgatás felfelé" #: templates/backoffice/respondent_field_schema/_editor.html:100 -#: templates/backoffice/targets/bulk_edit_form.html:239 +#: templates/backoffice/targets/bulk_edit_form.html:240 msgid "Move down" msgstr "Mozgatás lefelé" @@ -9656,74 +9739,74 @@ msgstr "például: Nem" msgid "Add" msgstr "Hozzáadás" -#: templates/backoffice/targets/bulk_edit_form.html:43 +#: templates/backoffice/targets/bulk_edit_form.html:44 msgid "the new value" msgstr "az új érték" -#: templates/backoffice/targets/bulk_edit_form.html:69 +#: templates/backoffice/targets/bulk_edit_form.html:70 #, python-format msgid "Name of %(row)s" msgstr "%(row)s neve" -#: templates/backoffice/targets/bulk_edit_form.html:81 +#: templates/backoffice/targets/bulk_edit_form.html:82 msgid "Min and max were set by hand, not calculated from the percentage" msgstr "" "A minimum és a maximum kézzel beállított érték, nem a százalékból " "számított" -#: templates/backoffice/targets/bulk_edit_form.html:82 +#: templates/backoffice/targets/bulk_edit_form.html:83 msgid "Set by hand" msgstr "Kézzel beállítva" -#: templates/backoffice/targets/bulk_edit_form.html:88 +#: templates/backoffice/targets/bulk_edit_form.html:89 msgid "Min and max will be recalculated when you save." msgstr "A minimum és a maximum mentéskor újraszámítódik." -#: templates/backoffice/targets/bulk_edit_form.html:99 +#: templates/backoffice/targets/bulk_edit_form.html:100 #, python-format msgid "Population share for %(row)s, in percent" msgstr "%(row)s népességi aránya, százalékban" -#: templates/backoffice/targets/bulk_edit_form.html:111 +#: templates/backoffice/targets/bulk_edit_form.html:112 #, python-format msgid "Minimum for %(row)s" msgstr "%(row)s minimuma" -#: templates/backoffice/targets/bulk_edit_form.html:121 +#: templates/backoffice/targets/bulk_edit_form.html:122 #, python-format msgid "Maximum for %(row)s" msgstr "%(row)s maximuma" -#: templates/backoffice/targets/bulk_edit_form.html:129 +#: templates/backoffice/targets/bulk_edit_form.html:130 msgid "Why min and max were set by hand" msgstr "Miért lett kézzel beállítva a minimum és a maximum" -#: templates/backoffice/targets/bulk_edit_form.html:130 +#: templates/backoffice/targets/bulk_edit_form.html:131 #, python-format msgid "Comment on %(row)s" msgstr "Megjegyzés a következőhöz: %(row)s" -#: templates/backoffice/targets/bulk_edit_form.html:135 -#: templates/backoffice/targets/bulk_edit_form.html:145 +#: templates/backoffice/targets/bulk_edit_form.html:136 +#: templates/backoffice/targets/bulk_edit_form.html:146 msgid "Recalculate min and max from the percentage" msgstr "A minimum és a maximum újraszámolása a százalékból" -#: templates/backoffice/targets/bulk_edit_form.html:138 -#: templates/backoffice/targets/bulk_edit_form.html:152 +#: templates/backoffice/targets/bulk_edit_form.html:139 +#: templates/backoffice/targets/bulk_edit_form.html:153 msgid "Delete value" msgstr "Érték törlése" -#: templates/backoffice/targets/bulk_edit_form.html:198 +#: templates/backoffice/targets/bulk_edit_form.html:199 #, python-format msgid "'%(name)s' and all its values will be deleted when you save." msgstr "\"%(name)s\" és az összes értéke törlődik, amikor mentesz." -#: templates/backoffice/targets/bulk_edit_form.html:220 +#: templates/backoffice/targets/bulk_edit_form.html:221 msgid "Source of population figures" msgstr "Népességi adatok forrása" -#: templates/backoffice/targets/bulk_edit_form.html:221 -#: templates/backoffice/targets/category_block.html:60 +#: templates/backoffice/targets/bulk_edit_form.html:222 +#: templates/backoffice/targets/category_block.html:61 msgid "" "A link to the report the percentages come from, such as census tables or " "a poll." @@ -9731,26 +9814,26 @@ msgstr "" "Link ahhoz a jelentéshez, amelyből a százalékos arányok származnak, " "például népszámlálási táblák vagy közvélemény-kutatás." -#: templates/backoffice/targets/bulk_edit_form.html:231 +#: templates/backoffice/targets/bulk_edit_form.html:232 msgid "Move this target down" msgstr "Célszám mozgatása lefelé" -#: templates/backoffice/targets/bulk_edit_form.html:232 +#: templates/backoffice/targets/bulk_edit_form.html:233 msgid "Move this target up" msgstr "Célszám mozgatása felfelé" -#: templates/backoffice/targets/bulk_edit_form.html:245 +#: templates/backoffice/targets/bulk_edit_form.html:246 msgid "Delete target" msgstr "Célszám törlése" -#: templates/backoffice/targets/bulk_edit_form.html:254 -#: templates/backoffice/targets/category_block.html:122 +#: templates/backoffice/targets/bulk_edit_form.html:255 +#: templates/backoffice/targets/category_block.html:123 #, python-format msgid "Population (%%)" msgstr "Népesség (%%)" -#: templates/backoffice/targets/bulk_edit_form.html:260 -#: templates/backoffice/targets/category_block.html:128 +#: templates/backoffice/targets/bulk_edit_form.html:261 +#: templates/backoffice/targets/category_block.html:129 msgid "" "Counts people in the pool, and those selected or confirmed. Withdrawn " "people and test submissions are not counted." @@ -9759,8 +9842,8 @@ msgstr "" "embereket számolja. A visszalépett emberek és a tesztbeküldések nem " "számítanak bele." -#: templates/backoffice/targets/bulk_edit_form.html:269 -#: templates/backoffice/targets/category_block.html:137 +#: templates/backoffice/targets/bulk_edit_form.html:270 +#: templates/backoffice/targets/category_block.html:138 msgid "" "Counts people who have been selected, including those who have since " "confirmed their place." @@ -9768,58 +9851,51 @@ msgstr "" "A kiválasztott embereket számolja, beleértve azokat is, akik azóta " "megerősítették a részvételüket." -#: templates/backoffice/targets/bulk_edit_form.html:312 +#: templates/backoffice/targets/bulk_edit_form.html:318 #: templates/targets/components/category_block.html:247 msgid "Add value" msgstr "Érték hozzáadása" -#: templates/backoffice/targets/bulk_edit_form.html:317 +#: templates/backoffice/targets/bulk_edit_form.html:323 msgid "Add values found in respondent data" msgstr "A jelentkezői adatokban talált értékek hozzáadása" -#: templates/backoffice/targets/bulk_edit_form.html:322 +#: templates/backoffice/targets/bulk_edit_form.html:328 #, python-format msgid "Not yet targeted: %(values)s" msgstr "Még nincs hozzájuk célszám: %(values)s" -#: templates/backoffice/targets/bulk_edit_form.html:332 -#: templates/backoffice/targets/category_block.html:211 +#: templates/backoffice/targets/bulk_edit_form.html:338 +#: templates/backoffice/targets/category_block.html:212 #: templates/targets/components/category_block.html:195 msgid "Total" msgstr "Összesen" -#: templates/backoffice/targets/category_block.html:11 -#: templates/backoffice/targets/category_block.html:14 +#: templates/backoffice/targets/category_block.html:12 +#: templates/backoffice/targets/category_block.html:15 msgid "Manually modified" msgstr "Kézzel módosítva" -#: templates/backoffice/targets/category_block.html:18 -#: templates/backoffice/targets/category_block.html:21 +#: templates/backoffice/targets/category_block.html:19 +#: templates/backoffice/targets/category_block.html:22 msgid "Auto calculated" msgstr "Automatikusan számolva" -#: templates/backoffice/targets/category_block.html:59 +#: templates/backoffice/targets/category_block.html:60 msgid "Source of population figures:" msgstr "Népességi adatok forrása:" -#: templates/backoffice/targets/category_block.html:95 -#: templates/backoffice/targets/category_block.html:186 -#: templates/targets/components/category_block.html:59 -#: templates/targets/components/category_block.html:183 -msgid "Suggestion" -msgstr "Javaslat" - -#: templates/backoffice/targets/category_block.html:204 +#: templates/backoffice/targets/category_block.html:205 #: templates/targets/components/category_block.html:208 msgid "No values defined." msgstr "Nincsenek meghatározott értékek." -#: templates/backoffice/targets/category_block.html:250 +#: templates/backoffice/targets/category_block.html:251 #: templates/targets/components/category_block.html:223 msgid "Values in respondent data not yet targeted:" msgstr "A jelentkezői adatokban szereplő, még célszám nélküli értékek:" -#: templates/backoffice/targets/category_block.html:252 +#: templates/backoffice/targets/category_block.html:253 msgid "Add them under \"Edit targets\"." msgstr "Add hozzá őket a „Célszámok szerkesztése” alatt." @@ -9887,6 +9963,16 @@ msgid_plural "and %(num)s others" msgstr[0] "és még %(num)s" msgstr[1] "és még %(num)s" +#: templates/backoffice/targets/value_suggestions.html:22 +#, python-format +msgid "Accepted algorithm suggestion — minimum %(current)s → %(suggested)s" +msgstr "" + +#: templates/backoffice/targets/value_suggestions.html:24 +#, python-format +msgid "Accepted algorithm suggestion — maximum %(current)s → %(suggested)s" +msgstr "" + #: templates/db_selection/replace.html:2 msgid "Replacements (DB)" msgstr "Résztvevők pótlása (adatbázis)" @@ -11476,14 +11562,14 @@ msgid_plural "%(count)s rows" msgstr[0] "%(count)s sor" msgstr[1] "%(count)s sor" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:33 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:33 #, python-format msgid "No '%(column)s' column %(error_label)s found in %(data_container)s!" msgstr "" "Nem található '%(column)s' oszlop %(error_label)s ebben: " "%(data_container)s!" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:34 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:34 #, python-format msgid "" "MORE THAN 1 '%(column)s' column %(error_label)s found in " @@ -11492,7 +11578,7 @@ msgstr "" "TÖBB MINT 1 '%(column)s' oszlop %(error_label)s található ebben: " "%(data_container)s!" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:36 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:36 #, python-format msgid "" "Failed to find person at position %(position)s in " @@ -11501,16 +11587,16 @@ msgstr "" "Nem található jelölt %(position)s pozícióban itt: " "%(feature_name)s/%(feature_value)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:37 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:37 msgid "Logic error - trying to create new tab before choosing suffix" msgstr "Logikai hiba – új munkalap létrehozása az utótag kiválasztása előtt" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:38 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:38 #, python-format msgid "Google spreadsheet not found: %(spreadsheet_name)s." msgstr "A Google Táblázat nem található: %(spreadsheet_name)s." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:40 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:40 #, python-format msgid "" "Google spreadsheet %(spreadsheet_name)s exists but is not shared with the" @@ -11521,7 +11607,7 @@ msgstr "" "szolgáltatásfiókkal: %(service_account_email)s. Oszd meg ezzel az e-mail " "címmel, és próbáld újra." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:44 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:44 #, python-format msgid "" "Google spreadsheet '%(title)s' is shared read-only with " @@ -11532,7 +11618,7 @@ msgstr "" "címmel: %(service_account_email)s. Adj neki szerkesztési jogot, hogy a " "kimeneti munkalapok írhatók legyenek." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:48 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:48 #, python-format msgid "" "Error in Google sheet: no tab called '%(tab_name)s' found in spreadsheet " @@ -11541,7 +11627,7 @@ msgstr "" "Hiba a Google Táblázatban: nem található '%(tab_name)s' nevű munkalap " "'%(spreadsheet_title)s' táblázatban." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:51 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:51 #, python-format msgid "" "The file '%(file_name)s' is a %(common_name)s (mimetype %(mimetype)s), " @@ -11553,19 +11639,19 @@ msgstr "" " Mentés Google-táblázatként lehetőséget, majd használd az így létrejött " "új fájlt." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:55 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:55 #, python-format msgid "" "It seems like some variables do not have a value. Original exception: " "%(error)s." msgstr "Úgy tűnik, néhány változónak nincs értéke. Eredeti kivétel: %(error)s." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:57 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:57 #, python-format msgid "the header row in the %(tab_name)s tab contains duplicates: %(duplicates)s" msgstr "%(tab_name)s munkalap fejlécsora ismétlődéseket tartalmaz: %(duplicates)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:61 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:61 msgid "" "Running the test selection does not support generating a transparent " "lottery, so, if `test_selection` is true, `number_selections` must be 1." @@ -11574,7 +11660,7 @@ msgstr "" "előállítását, ezért ha a `test_selection` értéke igaz, a " "`number_selections` értékének 1-nek kell lennie." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:65 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:65 msgid "" "Currently, the legacy algorithm does not support generating a transparent" " lottery, so `number_selections` must be set to 1." @@ -11582,7 +11668,7 @@ msgstr "" "A régi algoritmus jelenleg nem támogatja az átlátható sorsolás " "előállítását, ezért a `number_selections` értékét 1-re kell állítani." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:69 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:69 msgid "" "The diversimax algorithm does not support generating multiple committees," " so `number_selections` must be set to 1." @@ -11590,7 +11676,7 @@ msgstr "" "A diversimax algoritmus nem támogatja több csoport-összeállítás " "előállítását, ezért a `number_selections` értékét 1-re kell állítani." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:73 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:73 #, python-format msgid "" "Unknown selection algorithm %(algorithm)r, must be either 'legacy', " @@ -11599,24 +11685,24 @@ msgstr "" "Ismeretlen kiválasztási algoritmus: %(algorithm)r, a következők egyike " "kell legyen: 'legacy', 'leximin', 'maximin', 'diversimax' vagy 'nash'." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:75 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:75 #, python-format msgid "solver_backend %(backend)s is not one of: %(valid_backends)s" msgstr "a solver_backend %(backend)s nem szerepel ezek között: %(valid_backends)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:76 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:76 #, python-format msgid "Unknown solver backend: %(backend)s" msgstr "Ismeretlen megoldó backend: %(backend)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:77 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:77 #, python-format msgid "Could not find feature column, looked for column headers: %(column_names)s" msgstr "" "Nem található a kiválasztási szempont oszlopa, a keresett oszlopfejlécek:" " %(column_names)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:79 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:79 #, python-format msgid "" "Could not find feature value column, looked for column headers: " @@ -11625,12 +11711,12 @@ msgstr "" "Nem található a szempontérték oszlopa, a keresett oszlopfejlécek: " "%(column_names)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:81 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:81 #, python-format msgid "Did not find required column name '%(field_name)s' in the input" msgstr "A kötelező '%(field_name)s' oszlopnév nem található a bemenetben" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:82 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:82 #, python-format msgid "" "Found MORE THAN 1 column named '%(field_name)s' in the input (found " @@ -11639,41 +11725,41 @@ msgstr "" "TÖBB MINT 1 '%(field_name)s' nevű oszlop található a bemenetben (talált: " "%(count)s)" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:83 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:83 #, python-format msgid "Unexpected error in set of column names: %(headers)s" msgstr "Váratlan hiba az oszlopnevek halmazában: %(headers)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:85 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:85 msgid "check_same_address_columns must be a LIST of strings" msgstr "a check_same_address_columns értékének szövegek LISTÁJÁNAK kell lennie" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:86 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:86 msgid "check_same_address_columns must be a list of STRINGS" msgstr "a check_same_address_columns értékének SZÖVEGEK listájának kell lennie" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:87 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:87 msgid "check_same_address is TRUE but there are no columns listed to check." msgstr "" "a check_same_address értéke IGAZ, de nincs ellenőrizendő oszlop " "felsorolva." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:88 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:88 msgid "columns_to_keep must be a LIST of strings" msgstr "a columns_to_keep értékének szövegek LISTÁJÁNAK kell lennie" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:89 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:89 msgid "columns_to_keep must be a list of STRINGS" msgstr "a columns_to_keep értékének SZÖVEGEK listájának kell lennie" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:90 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:90 #, python-format msgid "selection_algorithm %(algorithm)s is not one of: %(valid_algorithms)s" msgstr "" "a selection_algorithm %(algorithm)s nem szerepel ezek között: " "%(valid_algorithms)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:93 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:93 msgid "" "Diversimax algorithm requires the optional 'diversimax' dependencies, " "which are not available" @@ -11681,99 +11767,99 @@ msgstr "" "A diversimax algoritmushoz szükség van az opcionális 'diversimax' " "függőségekre, amelyek nem érhetők el" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:95 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:95 msgid "Leximin algorithm requires Gurobi solver which is not available" msgstr "A leximin algoritmushoz a Gurobi megoldó szükséges, amely nem érhető el" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:100 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:100 #, python-format msgid "There is no %(field)s value set" msgstr "Nincs beállítva %(field)s érték" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:101 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:101 #, python-format msgid "'%(value)s' is not a number" msgstr "'%(value)s' nem szám" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:102 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:102 #, python-format msgid "Empty %(value_column_name)s in %(feature_column_name)s %(feature_name)s" msgstr "Üres %(value_column_name)s ebben: %(feature_column_name)s %(feature_name)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:103 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:103 #, python-format msgid "Minimum (%(min)s) should not be greater than maximum (%(max)s)" msgstr "A minimum (%(min)s) nem lehet nagyobb a maximumnál (%(max)s)" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:104 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:104 #, python-format msgid "min_flex (%(min_flex)s) should not be greater than min (%(min)s)" msgstr "a min_flex (%(min_flex)s) nem lehet nagyobb a min értéknél (%(min)s)" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:105 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:105 #, python-format msgid "max_flex (%(max_flex)s) should not be less than max (%(max)s)" msgstr "a max_flex (%(max_flex)s) nem lehet kisebb a max értéknél (%(max)s)" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:107 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:107 #, python-format msgid "Value '%(value)s' not in %(feature_column_name)s %(feature_name)s" msgstr "" "'%(value)s' érték nem szerepel itt: %(feature_column_name)s " "%(feature_name)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:108 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:108 #, python-format msgid "Empty value in %(feature_column_name)s %(feature_name)s" msgstr "Üres érték itt: %(feature_column_name)s %(feature_name)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:112 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:112 #, python-format msgid "%(msg)s: for row %(row)s, column header %(key)s" msgstr "%(msg)s: %(row)s. sorban, %(key)s oszlopfejlécnél" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:113 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:113 #, python-format msgid "%(msg)s: for row %(row)s, column headers %(keys)s" msgstr "%(msg)s: %(row)s. sorban, %(keys)s oszlopfejléceknél" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:117 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:117 #, python-format msgid "Parser error(s) while reading features from '%(tab_name)s' worksheet" msgstr "" "Feldolgozási hiba a kiválasztási szempontok beolvasása közben " "'%(tab_name)s' munkalapról" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:118 -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:119 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:118 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:119 #, python-format msgid "Parser error(s) while reading people from '%(tab_name)s' worksheet" msgstr "Feldolgozási hiba az emberek beolvasása közben '%(tab_name)s' munkalapról" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:120 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:120 #, python-format msgid "%(msg)s - see cell %(cell_name)s" msgstr "%(msg)s – lásd %(cell_name)s cellát" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:121 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:121 #, python-format msgid "%(msg)s - see cells %(cell_names)s" msgstr "%(msg)s – lásd %(cell_names)s cellákat" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:125 -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:109 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:125 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:109 #, python-format msgid "Found %(count)s IDs that have more than one row with different data" msgstr "" "%(count)s olyan azonosító található, amelyhez több, eltérő adatú sor " "tartozik" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:126 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:126 #, python-format msgid "For ID '%(person_id)s' one row of data is: %(row_data)s" msgstr "'%(person_id)s' azonosítóhoz tartozó egyik adatsor: %(row_data)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:128 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:128 #, python-format msgid "" "Not enough people with the value '%(feature_value)s' in category " @@ -11782,7 +11868,7 @@ msgstr "" "Nincs elég ember '%(feature_name)s' kiválasztási szempont " "'%(feature_value)s' értékével – a minimum %(min)s, de csak %(count)s van" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:132 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:132 #, python-format msgid "" "The number of people to select (%(desired)s) is out of the range of the " @@ -11793,7 +11879,7 @@ msgstr "" "szempontnál szereplő létszámok tartományán. A [%(min)s, %(max)s] " "tartományba kell esnie." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:138 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:138 msgid "" "It is not possible to hit all the targets with the current set of people." " I suggest the following steps:" @@ -11801,12 +11887,12 @@ msgstr "" "A jelenlegi emberekkel nem lehet minden célszámot elérni. A következő " "lépéseket javaslom:" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:141 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:141 #, python-format msgid "Inconsistent numbers in min and max in the %(feature_column_name)s input:" msgstr "Ellentmondásos min. és max. számok %(feature_column_name)s bemenetben:" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:142 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:142 #, python-format msgid "" "The smallest maximum is %(max_val)s for %(feature_column_name)s " @@ -11815,7 +11901,7 @@ msgstr "" "A legkisebb maximum %(max_val)s '%(max_feature)s' %(feature_column_name)s" " értéknél" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:143 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:143 #, python-format msgid "" "The largest minimum is %(min_val)s for %(feature_column_name)s " @@ -11824,7 +11910,7 @@ msgstr "" "A legnagyobb minimum %(min_val)s '%(min_feature)s' " "%(feature_column_name)s értéknél" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/error_messages.py:145 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/error_messages.py:145 #, python-format msgid "" "You need to reduce the minimums for %(min_feature)s or increase the " @@ -11833,38 +11919,38 @@ msgstr "" "Csökkentened kell %(min_feature)s minimumait, vagy növelned " "%(max_feature)s maximumait." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:32 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:32 msgid "Loading features from string." msgstr "Kiválasztási szempontok betöltése szövegből." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:33 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:33 msgid "Loading people from string." msgstr "Emberek betöltése szövegből." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:34 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:34 msgid "Loading already selected people from string." msgstr "A már kiválasztott emberek betöltése szövegből." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:35 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:35 msgid "No already selected data provided, using empty data." msgstr "Nem adtál meg már kiválasztott adatokat, üres adatokkal folytatódik." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:36 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:36 #, python-format msgid "Loading features from file %(file_path)s." msgstr "Kiválasztási szempontok betöltése %(file_path)s fájlból." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:37 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:37 #, python-format msgid "Loading people from file %(file_path)s." msgstr "Emberek betöltése %(file_path)s fájlból." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:38 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:38 #, python-format msgid "Loading already selected people from file %(file_path)s." msgstr "A már kiválasztott emberek betöltése %(file_path)s fájlból." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:39 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:39 msgid "" "No already selected file provided or file does not exist, using empty " "data." @@ -11872,23 +11958,23 @@ msgstr "" "Nem adtál meg már kiválasztottakat tartalmazó fájlt, vagy a fájl nem " "létezik; üres adatokkal folytatódik." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:40 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:40 #, python-format msgid "Reading in '%(tab_name)s' tab in above Google sheet." msgstr "'%(tab_name)s' munkalap beolvasása a fenti Google Táblázatból." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:41 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:41 msgid "No already selected tab specified, using empty data." msgstr "" "Nem adtál meg már kiválasztottakat tartalmazó munkalapot, üres adatokkal " "folytatódik." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:42 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:42 #, python-format msgid "Number of features found: %(count)s" msgstr "Talált kiválasztási szempontok száma: %(count)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:44 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:44 #, python-format msgid "" "Reading in '%(tab_name)s' tab (header at row %(header_row)s) in above " @@ -11897,32 +11983,32 @@ msgstr "" "'%(tab_name)s' munkalap beolvasása (fejléc %(header_row)s. sorban) a " "fenti Google Táblázatból." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:46 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:46 #, python-format msgid "Opened Google Sheet: '%(title)s'." msgstr "Megnyitott Google Táblázat: '%(title)s'." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:50 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:50 msgid "Using legacy algorithm." msgstr "A régi algoritmus fut." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:51 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:51 msgid "Using maximin algorithm." msgstr "A maximin algoritmus fut." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:52 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:52 msgid "Using leximin algorithm." msgstr "A leximin algoritmus fut." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:53 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:53 msgid "Using Nash algorithm." msgstr "A Nash-algoritmus fut." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:54 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:54 msgid "Had to switch to ECOS solver." msgstr "Váltás szükséges volt az ECOS megoldóra." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:56 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:56 msgid "" "The leximin algorithm requires the optimization library Gurobi to be " "installed (commercial, free academic licenses available). Switching to " @@ -11934,7 +12020,7 @@ msgstr "" "az egyszerűbb maximin algoritmusra, amely nyílt forráskódú megoldókkal is" " futtatható." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:61 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:61 #, python-format msgid "" "Algorithm produced distribution over %(total_committees)s committees, out" @@ -11944,7 +12030,7 @@ msgstr "" " állított elő, amelyek közül %(non_zero_committees)s kerül kiválasztásra " "pozitív valószínűséggel." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:65 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:65 #, python-format msgid "" "INFO: The distribution over panels is what is known as a 'basic " @@ -11960,19 +12046,19 @@ msgstr "" "adatokkal: algorithm=%(algorithm)s, num_panels=%(num_panels)s, " "num_agents=%(num_agents)s, min_probs=%(min_probs)s." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:70 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:70 #, python-format msgid "Agent %(agent_id)s not contained in any feasible committee." msgstr "" "%(agent_id)s azonosítójú jelölt egyetlen megvalósítható csoport-" "összeállításban sem szerepel." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:71 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:71 #, python-format msgid "Scaled Nash welfare is now: %(scaled_welfare)s." msgstr "A skálázott Nash-jólét mostantól: %(scaled_welfare)s." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:73 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:73 #, python-format msgid "" "Maximin is at most %(at_most)s, can do %(upper_str)s with " @@ -11981,7 +12067,7 @@ msgstr "" "A maximin legfeljebb %(at_most)s, %(num_committees)s csoport-" "összeállítással %(upper_str)s érhető el. Eltérés: %(gap_str)s." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:76 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:76 #, python-format msgid "" "Leximin is at most %(at_most)s, can do %(dual_obj_str)s with " @@ -11990,38 +12076,38 @@ msgstr "" "A leximin legfeljebb %(at_most)s, %(num_committees)s csoport-" "összeállítással %(dual_obj_str)s érhető el. Eltérés: %(gap_str)s." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:81 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:81 msgid "WARNING: Panel is not selected at random! Only use for testing!" msgstr "" "FIGYELEM: A csoport-összeállítás nem véletlenszerűen lett kiválasztva! " "Csak tesztelésre használd!" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:82 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:82 msgid "Initial: (selected = 0)" msgstr "Kezdet: (kiválasztva = 0)" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:83 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:83 #, python-format msgid "Trial number: %(trial)s" msgstr "Kísérlet száma: %(trial)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:84 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:84 msgid "SUCCESS!! Final:" msgstr "SIKER!! Végeredmény:" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:85 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:85 #, python-format msgid "Failed %(attempts)s times. Gave up." msgstr "%(attempts)s alkalommal sikertelen volt. A futás megszakadt." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:86 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:86 #, python-format msgid "Failed one attempt. Selection Error raised - will retry. %(error)s" msgstr "" "Egy kísérlet sikertelen volt. Kiválasztási hiba történt – újrapróbálkozás" " következik. %(error)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:87 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:87 msgid "" "No target checks done for multiple selections - please see your output " "files." @@ -12029,7 +12115,7 @@ msgstr "" "Több kiválasztás esetén nem történik célszám-ellenőrzés – nézd meg a " "kimeneti fájljaidat." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:89 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:89 msgid "" "We do not calculate target details for multiple selections - please see " "your output files." @@ -12037,27 +12123,27 @@ msgstr "" "Több kiválasztás esetén nem készül célszám-részletezés – nézd meg a " "kimeneti fájljaidat." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:94 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:94 #, python-format msgid "Writing selected rows to %(file_path)s" msgstr "A kiválasztott sorok írása ide: %(file_path)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:95 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:95 #, python-format msgid "Writing remaining rows to %(file_path)s" msgstr "A fennmaradó sorok írása ide: %(file_path)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:96 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:96 #, python-format msgid "Writing selected people to tab: %(tab_name)s" msgstr "A kiválasztott emberek írása erre a munkalapra: %(tab_name)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:97 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:97 #, python-format msgid "Writing remaining people to tab: %(tab_name)s" msgstr "A fennmaradó emberek írása erre a munkalapra: %(tab_name)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:99 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:99 msgid "" "Finished writing selected (only, because writing remaining is deselected " "in the configuration)" @@ -12065,66 +12151,66 @@ msgstr "" "A kiválasztottak kiírása kész (csak azoké, mert a fennmaradók kiírása ki " "van kapcsolva a beállításokban)" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:101 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:101 msgid "Finished writing both selected and remaining" msgstr "A kiválasztottak és a fennmaradók kiírása is kész" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:105 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:105 #, python-format msgid "WARNING: blank cell found in ID column in row %(row)s - skipped that line!" msgstr "" "FIGYELEM: üres cella az azonosító oszlopban %(row)s. sorban – ez a sor " "kimaradt!" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:106 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:106 msgid "WARNING: Duplicate people found:" msgstr "FIGYELEM: Ismétlődő emberek találhatók:" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:107 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:107 #, python-format msgid "Person with ID '%(person_id)s' appears %(count)s times in rows: %(rows)s" msgstr "" "'%(person_id)s' azonosítójú személy %(count)s alkalommal szerepel a " "következő sorokban: %(rows)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:108 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:108 #, python-format msgid "Found %(count)s IDs that have more than one row" msgstr "%(count)s olyan azonosító található, amelyhez több sor tartozik" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:110 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:110 #, python-format msgid "Duplicated IDs are: %(ids)s" msgstr "Az ismétlődő azonosítók: %(ids)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:111 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:111 msgid "All duplicate rows have identical data - processing continuing." msgstr "" "Minden ismétlődő sor azonos adatokat tartalmaz – a feldolgozás " "folytatódik." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:112 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:112 msgid "All agents are contained in some feasible committee." msgstr "Minden jelölt szerepel valamelyik megvalósítható csoport-összeállításban." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:113 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:113 #, python-format msgid "Heuristic successfully generated %(count)s additional committees." msgstr "" "A heurisztika sikeresen létrehozott %(count)s további csoport-" "összeállítást." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:117 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:117 #, python-format msgid "Using these settings: %(settings)s" msgstr "A használt beállítások: %(settings)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:118 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:118 #, python-format msgid "Random seed set to: %(seed)s" msgstr "A véletlenszám-mag beállítva erre: %(seed)s" -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:120 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:120 #, python-format msgid "" "Wrote default settings to '%(file_path)s' - if editing is required, " @@ -12133,7 +12219,7 @@ msgstr "" "Az alapértelmezett beállítások ide íródtak: '%(file_path)s' – ha " "szerkesztésre van szükség, indítsd újra ezt az alkalmazást." -#: .venv/lib/python3.12/site-packages/sortition_algorithms/report_messages.py:123 +#: .venv/lib/python3.14/site-packages/sortition_algorithms/report_messages.py:123 msgid "" "WARNING: Settings file is such that we do NOT check if respondents have " "same address."