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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions kompassi/dimensions/models/dimension_dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,24 @@ class DimensionDTO(pydantic.BaseModel):

choices: list[DimensionValueDTO] | None = pydantic.Field(default=None)

@classmethod
def from_dimension(cls, dimension: Dimension) -> Self:
return cls(
slug=dimension.slug,
title=dimension.title_dict,
order=dimension.order,
is_public=dimension.is_public,
is_key_dimension=dimension.is_key_dimension,
is_multi_value=dimension.is_multi_value,
is_list_filter=dimension.is_list_filter,
is_shown_in_detail=dimension.is_shown_in_detail,
is_negative_selection=dimension.is_negative_selection,
is_technical=dimension.is_technical,
can_values_be_added=dimension.can_values_be_added,
value_ordering=dimension.value_ordering,
choices=[DimensionValueDTO.from_dimension_value(value) for value in dimension.values.all()],
)

def save(
self,
universe: Universe,
Expand Down
17 changes: 17 additions & 0 deletions kompassi/dimensions/models/dimension_value_dto.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Self

import pydantic

if TYPE_CHECKING:
from .dimension_value import DimensionValue


class DimensionValueDTO(pydantic.BaseModel):
slug: str
title: dict[str, str]
color: str = ""
is_technical: bool = False
is_subject_locked: bool = False

@classmethod
def from_dimension_value(cls, dimension_value: DimensionValue) -> Self:
return cls(
slug=dimension_value.slug,
title=dimension_value.title_dict,
color=dimension_value.color,
is_technical=dimension_value.is_technical,
is_subject_locked=dimension_value.is_subject_locked,
)
19 changes: 19 additions & 0 deletions kompassi/dimensions/models/universe.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,25 @@ def surveys(self):
case _:
raise ValueError(f"Unknown app type: {self.app}")

def clone(self, *, scope: Scope, slug: str) -> Universe:
"""
Creates a new Universe in the given scope with a fresh copy of this Universe's
dimensions and values. The clone shares nothing with the original: changes to
either universe's dimensions and values do not affect the other.
"""
from .dimension_dto import DimensionDTO

new_universe = Universe.objects.create(scope=scope, slug=slug, app=self.app)

dimension_dtos = [
DimensionDTO.from_dimension(dimension)
for dimension in self.dimensions.prefetch_related("values").order_by("order")
]
if dimension_dtos:
DimensionDTO.save_many(new_universe, dimension_dtos)

return new_universe

def preload_dimensions(
self,
dimension_slugs: Collection[str] | None = None,
Expand Down
12 changes: 10 additions & 2 deletions kompassi/forms/models/survey.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ def with_mandatory_fields(self) -> Self:

return self

@transaction.atomic
def clone(
self,
event: Event,
Expand All @@ -438,7 +439,10 @@ def clone(
registry: Registry | None = None,
):
"""
Clones this survey with its language versions and dimensions (but not responses).
Clones this survey with its language versions (but not responses). A stand-alone
survey (app=FORMS) gets a fresh clone of this survey's Universe, including its
dimensions and values. A program form (app=PROGRAM) uses the target event's shared
program Universe as is; no dimension cloning applies there.
Some fields are not copied over because they make no sense or might cause data leaks.
"""
survey = Survey(
Expand All @@ -453,8 +457,12 @@ def clone(
retention_period=self.retention_period,
created_by=created_by,
registry=self.registry if registry is None else registry,
).with_mandatory_fields()
)

if app == DimensionApp.FORMS:
survey.universe = self.universe.clone(scope=event.scope, slug=slug)

survey.with_mandatory_fields()
survey.save()

cloned_forms = [form.clone(survey, created_by=created_by) for form in self.languages.all()]
Expand Down
70 changes: 70 additions & 0 deletions kompassi/forms/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -1540,3 +1540,73 @@ def test_update_survey_retention_period_days_round_trip(_patched_graphql_check_i

survey.refresh_from_db()
assert survey.retention_period is None


@pytest.mark.django_db
def test_survey_clone_clones_universe_for_standalone_survey():
"""
Cloning a stand-alone survey (app=FORMS) gives the clone its own independent copy of
the original survey's dimensions and values, not a reference to the same Universe.
"""
event, _created = Event.get_or_create_dummy()

survey = Survey.objects.create(event=event, slug="clone-source")

dimension = Dimension.objects.create(
universe=survey.universe,
slug="color",
title_en="Color",
is_key_dimension=True,
)
DimensionValue.objects.create(dimension=dimension, slug="red", title_en="Red")
DimensionValue.objects.create(dimension=dimension, slug="blue", title_en="Blue")

clone = survey.clone(
event=event,
slug="clone-target",
app=DimensionApp.FORMS,
purpose=SurveyPurpose.DEFAULT,
)

assert clone.universe_id != survey.universe_id
assert clone.universe.scope_id == survey.universe.scope_id
assert clone.universe.slug == clone.slug

cloned_dimension = clone.universe.dimensions.get(slug="color")
assert cloned_dimension.id != dimension.id
assert cloned_dimension.title_en == "Color"
assert cloned_dimension.is_key_dimension is True
assert set(cloned_dimension.values.values_list("slug", flat=True)) == {"red", "blue"}

# the two universes are independent: changing one does not affect the other
DimensionValue.objects.create(dimension=cloned_dimension, slug="green", title_en="Green")
assert set(dimension.values.values_list("slug", flat=True)) == {"red", "blue"}


@pytest.mark.django_db
def test_survey_clone_uses_target_event_program_universe():
"""
Cloning into a program form (app=PROGRAM) uses the target event's shared program
Universe as is; no dimensions are cloned from the source survey.
"""
from kompassi.program_v2.models.meta import ProgramV2EventMeta

event, _created = Event.get_or_create_dummy()
meta, _created = ProgramV2EventMeta.get_or_create_dummy()
target_event = meta.event

survey = Survey.objects.create(event=event, slug="clone-source-2")
Dimension.objects.create(universe=survey.universe, slug="color")

dimension_count_before = target_event.program_universe.dimensions.count()

clone = survey.clone(
event=target_event,
slug="clone-target-2",
app=DimensionApp.PROGRAM,
purpose=SurveyPurpose.DEFAULT,
)

assert clone.universe_id == target_event.program_universe.id
assert clone.universe.dimensions.count() == dimension_count_before
assert not clone.universe.dimensions.filter(slug="color").exists()
Loading