Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/v2plan-1f-121-repoint-saved-items.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PATCH /v2/user-policies/{id} accepts policy_id and PATCH /v2/user-households/{id} accepts household_id, so a saved policy or household can point at another v2 record in its country while keeping its ID, name and creation time. Supplied alone, the v2 reference clears the v1 reference (legacy_policy_id or legacy_household_id); supplied with it, both are stored, and the v2 reference must be the v2 copy of that v1 record when one exists. Unknown, other-country and conflicting records are refused as on creation, and saved policies maintained by the v1 saved-policy mirror still return 409.
10 changes: 10 additions & 0 deletions docs/migration/stage-12a-user-login.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,16 @@ Concurrency on Postgres:
`legacy_policy_mappings` / `legacy_household_mappings` when a mapping
exists.
- Changing the legacy ID re-resolves the v2 reference, or clears it.
- `PATCH /v2/user-policies` also accepts `policy_id`, which must name a v2
policy in the row's country. Supplied alone, it points the row at that
policy and clears `legacy_policy_id`, so an edited reform keeps its saved
ID, name and creation time. Supplied with `legacy_policy_id`, both are
stored, and `policy_id` must be the mirror copy of that v1 policy when
one exists.
- `PATCH /v2/user-households` accepts `household_id` the same way: it must
name a v2 household in the row's country; alone, it clears
`legacy_household_id`; with `legacy_household_id`, both are stored, and
it must be the copy of that v1 household when one exists.
- `user-simulations` and `user-reports` accept only legacy IDs in 12A.
- **Stage 10 mirror rows.** A `user_policies` row with a
`legacy_user_policy_mappings` entry returns 409 on PATCH and DELETE. The
Expand Down
9 changes: 7 additions & 2 deletions policyengine_api/fastapi_routes/v2/user_households/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,13 @@ def read() -> UserHouseholdPageResponse:
response_model=UserHouseholdDetailResponse,
summary="Update or repoint one of the current user's saved households",
description=(
"Changing legacy_household_id repoints the row and re-resolves "
"household_id from the Stage 11 copy mapping, or clears it."
"The row keeps its id, owner and creation time. Changing "
"legacy_household_id repoints the row and re-resolves household_id "
"from the Stage 11 copy mapping, or clears it. Changing household_id "
"alone points the row at that v2 household, which must exist in the "
"same country, and clears legacy_household_id. Supplied together, both "
"are stored, and household_id must be the copy of legacy_household_id "
"when one exists."
),
)
def patch_user_household_route(
Expand Down
10 changes: 7 additions & 3 deletions policyengine_api/fastapi_routes/v2/user_policies/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,13 @@ def read() -> UserPolicyPageResponse:
response_model=UserPolicyDetailResponse,
summary="Update or repoint one of the current user's saved policies",
description=(
"Changing legacy_policy_id repoints the row and re-resolves policy_id "
"from the Stage 10 mirror mapping, or clears it. Rows maintained by "
"the v1 saved-policy mirror return 409."
"The row keeps its id, owner and creation time. Changing "
"legacy_policy_id repoints the row and re-resolves policy_id from the "
"Stage 10 mirror mapping, or clears it. Changing policy_id alone points "
"the row at that v2 policy, which must exist in the same country, and "
"clears legacy_policy_id. Supplied together, both are stored, and "
"policy_id must be the mirror copy of legacy_policy_id when one exists. "
"Rows maintained by the v1 saved-policy mirror return 409."
),
)
def patch_user_policy_route(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
from typing import TYPE_CHECKING

from policyengine_api.data.v2.models.base import utc_now
from policyengine_api.services.v2.user_households.types import (
USER_HOUSEHOLD_REFERENCE_FIELDS,
)

if TYPE_CHECKING:
from uuid import UUID

from sqlmodel import Session

from policyengine_api.data.v2.models import UserHouseholdAssociation
from policyengine_api.services.v2.user_households.types import (
UserHouseholdReferences,
UserHouseholdUpdateInput,
)

Expand All @@ -22,17 +24,20 @@ def update_user_household(
association: UserHouseholdAssociation,
association_input: UserHouseholdUpdateInput,
*,
repointed_household_id: UUID | None = None,
repoint: bool = False,
references: UserHouseholdReferences | None = None,
) -> UserHouseholdAssociation:
"""Apply the supplied fields, bump ``updated_at`` and return the row.
"""Apply the supplied presentation fields, bump ``updated_at`` and return the row.

With ``repoint``, also set ``household_id`` to ``repointed_household_id``.
With ``references``, also store both of its household references; the
service resolves them, so the request's own reference fields are never copied.
"""
for field_name, value in association_input.model_dump(exclude_unset=True).items():
for field_name, value in association_input.model_dump(
exclude_unset=True, exclude=set(USER_HOUSEHOLD_REFERENCE_FIELDS)
).items():
setattr(association, field_name, value)
if repoint:
association.household_id = repointed_household_id
if references is not None:
association.household_id = references.household_id
association.legacy_household_id = references.legacy_household_id
association.updated_at = utc_now()
session.add(association)
session.flush()
Expand Down
37 changes: 23 additions & 14 deletions policyengine_api/services/v2/user_households/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from policyengine_api.services.v2.user_households.types import (
UserHouseholdFilter,
UserHouseholdListing,
UserHouseholdReferences,
)
from policyengine_api.services.v2.user_households.validators import (
require_user_household,
Expand Down Expand Up @@ -267,12 +268,17 @@ def patch_user_household(
) -> UserHouseholdRead:
"""Update the supplied fields of the principal's association in this country.

A new ``legacy_household_id`` must exist in v1 and repoints ``household_id``
to the v2 copy of that household, or to None when there is no copy. Raise
UserHouseholdNotFoundError when the association is not the principal's.
The association keeps its id, owner, country and creation time. A new
``legacy_household_id`` must exist in v1 (LegacyReferenceNotFoundError) and
repoints ``household_id`` to the v2 copy of that household, or to None when
there is no copy. A new ``household_id`` must exist in this country
(AssociationHouseholdNotFoundError or AssociationCountryConflictError).
Without ``legacy_household_id`` it clears the v1 reference; with it, both
are stored, and it must be the v2 copy of that v1 household when one exists
(AssociationReferenceConflictError). Raise UserHouseholdNotFoundError when
the association is not the principal's.
"""
repoint = "legacy_household_id" in association_input.model_fields_set
if repoint:
if "legacy_household_id" in association_input.model_fields_set:
if association_input.legacy_household_id is None:
msg = "legacy_household_id must not be null"
raise TypeError(msg)
Expand All @@ -290,22 +296,25 @@ def patch_user_household(
country_id=country_id,
association_id=association_id,
)
repointed_household_id = (
read_copied_household_ids(
session,
country_id=country_id,
legacy_household_ids=(association_input.legacy_household_id,),
).get(association_input.legacy_household_id)
if repoint and association_input.legacy_household_id is not None
references = (
UserHouseholdReferences(
household_id=resolve_user_household_reference(
session,
country_id=country_id,
household_id=association_input.household_id,
legacy_household_id=association_input.legacy_household_id,
),
legacy_household_id=association_input.legacy_household_id,
)
if association_input.repoints
else None
)
return user_household_read(
update_user_household(
session,
association,
association_input,
repointed_household_id=repointed_household_id,
repoint=repoint,
references=references,
)
)

Expand Down
41 changes: 32 additions & 9 deletions policyengine_api/services/v2/user_households/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,33 +61,56 @@ def require_household_reference(self) -> UserHouseholdCreationInput:
return self


USER_HOUSEHOLD_REFERENCE_FIELDS = frozenset({"household_id", "legacy_household_id"})


class UserHouseholdUpdateInput(StrictUserHouseholdInput):
"""A partial update of a saved household; only supplied fields are changed.

At least one field must be supplied. ``legacy_household_id`` may point the
association at another v1 household but cannot be cleared.
At least one field must be supplied. ``household_id`` points the association
at another v2 household; supplied without ``legacy_household_id``, it also
clears the v1 reference, which no longer describes the saved household.
``legacy_household_id`` alone points the association at another v1 household
and takes ``household_id`` from that household's v2 copy, or clears it when
there is none. Supplied together, both are stored, and ``household_id`` must
be that v2 copy when one exists. Neither reference can be set to null.
"""

household_id: ResourceId | None = None
legacy_household_id: LegacyRecordId | None = None
name: AssociationName | None = None
description: AssociationDescription | None = None

@model_validator(mode="after")
def require_valid_supplied_field(self) -> UserHouseholdUpdateInput:
"""Require a supplied field, and reject an explicit null legacy_household_id."""
mutable_fields = {"legacy_household_id", "name", "description"}
"""Require a supplied field, and reject an explicit null household reference."""
mutable_fields = {*USER_HOUSEHOLD_REFERENCE_FIELDS, "name", "description"}
supplied = self.model_fields_set.intersection(mutable_fields)
if not supplied:
msg = (
"At least one of legacy_household_id, name, or description must be "
"supplied"
"At least one of household_id, legacy_household_id, name, or "
"description must be supplied"
)
raise ValueError(msg)
if "legacy_household_id" in supplied and self.legacy_household_id is None:
msg = "legacy_household_id must not be null"
raise ValueError(msg)
for reference_field in sorted(USER_HOUSEHOLD_REFERENCE_FIELDS):
if reference_field in supplied and getattr(self, reference_field) is None:
msg = f"{reference_field} must not be null"
raise ValueError(msg)
return self

@property
def repoints(self) -> bool:
"""Whether the update changes which household the association references."""
return bool(self.model_fields_set & USER_HOUSEHOLD_REFERENCE_FIELDS)


@dataclass(frozen=True)
class UserHouseholdReferences:
"""The v2 and v1 household references a repointed association stores."""

household_id: UUID | None
legacy_household_id: int | None


@dataclass(frozen=True)
class UserHouseholdRead:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@
from typing import TYPE_CHECKING

from policyengine_api.data.v2.models.base import utc_now
from policyengine_api.services.v2.user_policies.types import (
USER_POLICY_REFERENCE_FIELDS,
)

if TYPE_CHECKING:
from uuid import UUID

from sqlmodel import Session

from policyengine_api.data.v2.models import LegacyUserPolicyMapping, UserPolicy
from policyengine_api.services.v2.user_policies.types import (
LegacyUserPolicyRevision,
UserPolicyReferences,
UserPolicyUpdateInput,
)

Expand All @@ -23,17 +25,20 @@ def update_user_policy(
association: UserPolicy,
association_input: UserPolicyUpdateInput,
*,
repointed_policy_id: UUID | None = None,
repoint: bool = False,
references: UserPolicyReferences | None = None,
) -> UserPolicy:
"""Apply the supplied fields, bump ``updated_at`` and return the row.
"""Apply the supplied presentation fields, bump ``updated_at`` and return the row.

With ``repoint``, also set ``policy_id`` to ``repointed_policy_id``.
With ``references``, also store both of its policy references; the service
resolves them, so the request's own reference fields are never copied.
"""
for field_name, value in association_input.model_dump(exclude_unset=True).items():
for field_name, value in association_input.model_dump(
exclude_unset=True, exclude=set(USER_POLICY_REFERENCE_FIELDS)
).items():
setattr(association, field_name, value)
if repoint:
association.policy_id = repointed_policy_id
if references is not None:
association.policy_id = references.policy_id
association.legacy_policy_id = references.legacy_policy_id
association.updated_at = utc_now()
session.add(association)
session.flush()
Expand Down
39 changes: 24 additions & 15 deletions policyengine_api/services/v2/user_policies/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
UserPolicyListing,
UserPolicyPage,
UserPolicyRead,
UserPolicyReferences,
UserPolicyUpdateInput,
)
from policyengine_api.services.v2.user_policies.validators import (
Expand Down Expand Up @@ -500,13 +501,18 @@ def patch_user_policy(
) -> UserPolicyRead:
"""Update the supplied fields of the principal's association in this country.

A new ``legacy_policy_id`` must exist in v1 and repoints ``policy_id`` to the
v2 copy of that policy, or to None when there is no copy. Raise
UserPolicyNotFoundError when the association is not the principal's, and
MirrorOwnedAssociationError when the v1 saved-policy mirror maintains it.
The association keeps its id, owner, country and creation time. A new
``legacy_policy_id`` must exist in v1 (LegacyReferenceNotFoundError) and
repoints ``policy_id`` to the v2 copy of that policy, or to None when there
is no copy. A new ``policy_id`` must exist in this country
(AssociationPolicyNotFoundError or AssociationCountryConflictError).
Without ``legacy_policy_id`` it clears the v1 reference; with it, both are
stored, and it must be the v2 copy of that v1 policy when one exists
(AssociationReferenceConflictError). Raise UserPolicyNotFoundError when the
association is not the principal's, and MirrorOwnedAssociationError when
the v1 saved-policy mirror maintains it.
"""
repoint = "legacy_policy_id" in association_input.model_fields_set
if repoint:
if "legacy_policy_id" in association_input.model_fields_set:
if association_input.legacy_policy_id is None:
msg = "legacy_policy_id must not be null"
raise TypeError(msg)
Expand All @@ -530,22 +536,25 @@ def patch_user_policy(
),
association_id=association.id,
)
repointed_policy_id = (
read_mirrored_policy_ids(
session,
country_id=country_id,
legacy_policy_ids=(association_input.legacy_policy_id,),
).get(association_input.legacy_policy_id)
if repoint and association_input.legacy_policy_id is not None
references = (
UserPolicyReferences(
policy_id=resolve_user_policy_reference(
session,
country_id=country_id,
policy_id=association_input.policy_id,
legacy_policy_id=association_input.legacy_policy_id,
),
legacy_policy_id=association_input.legacy_policy_id,
)
if association_input.repoints
else None
)
return user_policy_read(
update_user_policy(
session,
association,
association_input,
repointed_policy_id=repointed_policy_id,
repoint=repoint,
references=references,
)
)

Expand Down
Loading
Loading