Skip to content

Commit

Permalink
Merge pull request #400 from con2/feat/dimension-editor-contd
Browse files Browse the repository at this point in the history
Finish dimension editor
  • Loading branch information
japsu committed Feb 1, 2024
2 parents 5513ed3 + c2eccf2 commit 5d5d65c
Show file tree
Hide file tree
Showing 19 changed files with 656 additions and 153 deletions.
19 changes: 18 additions & 1 deletion backend/forms/graphql/dimension.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import graphene
from graphene_django import DjangoObjectType

from access.cbac import graphql_query_cbac_required
from graphql_api.utils import resolve_localized_field

from ..models import Dimension, DimensionValue, ResponseDimensionValue
Expand All @@ -11,15 +12,31 @@ class SurveyDimensionType(DjangoObjectType):
title = graphene.String(lang=graphene.String())
resolve_title = resolve_localized_field("title")

@staticmethod
# TODO should probably check for remove mutation permission here
@graphql_query_cbac_required
def resolve_can_remove(dimension: Dimension, info):
return dimension.can_remove

can_remove = graphene.NonNull(graphene.Boolean)

class Meta:
model = Dimension
fields = ("slug", "values", "is_key_dimension", "is_multi_value")
fields = ("slug", "values", "is_key_dimension", "is_multi_value", "is_shown_to_respondent")


class SurveyDimensionValueType(DjangoObjectType):
title = graphene.String(lang=graphene.String())
resolve_title = resolve_localized_field("title")

@staticmethod
# TODO should probably check for remove mutation permission here
@graphql_query_cbac_required
def resolve_can_remove(dimension: Dimension, info):
return dimension.can_remove

can_remove = graphene.NonNull(graphene.Boolean)

class Meta:
model = DimensionValue
fields = ("slug", "color")
Expand Down
5 changes: 3 additions & 2 deletions backend/forms/graphql/mutations/delete_survey_dimension.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ def mutate(
# TODO bastardization of graphql_check_access, rethink
graphql_check_access(survey, info, "dimensions", "mutation")

# TODO can_delete

dimension = survey.dimensions.get(slug=input.dimension_slug)
if not dimension.can_remove:
raise Exception("Cannot remove dimension that is in use")

dimension.delete()

return DeleteSurveyDimension(slug=input.survey_slug) # type: ignore
39 changes: 39 additions & 0 deletions backend/forms/graphql/mutations/delete_survey_dimension_value.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import graphene

from access.cbac import graphql_check_access

from ...models.dimension import DimensionValue
from ...models.survey import Survey


class DeleteSurveyDimensionValueInput(graphene.InputObjectType):
event_slug = graphene.String(required=True)
survey_slug = graphene.String(required=True)
dimension_slug = graphene.String(required=True)
value_slug = graphene.String(required=True)


class DeleteSurveyDimensionValue(graphene.Mutation):
class Arguments:
input = DeleteSurveyDimensionValueInput(required=True)

slug = graphene.Field(graphene.String)

@staticmethod
def mutate(
root,
info,
input: DeleteSurveyDimensionValueInput,
):
survey = Survey.objects.get(event__slug=input.event_slug, slug=input.survey_slug)

# TODO bastardization of graphql_check_access, rethink
graphql_check_access(survey, info, "dimensions", "mutation")

value = DimensionValue.objects.get(dimension__slug=input.dimension_slug, slug=input.value_slug)
if not value.can_remove:
raise Exception("Cannot remove dimension value that is in use")

value.delete()

return DeleteSurveyDimensionValue(slug=input.survey_slug) # type: ignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,37 @@
from ..dimension import SurveyDimensionType


class CreateSurveyDimensionInput(graphene.InputObjectType):
class PutSurveyDimensionInput(graphene.InputObjectType):
event_slug = graphene.String(required=True)
survey_slug = graphene.String(required=True)
dimension_slug = graphene.String(description="If set, update existing; otherwise, create new")
form_data = GenericScalar(required=True)


class CreateSurveyDimension(graphene.Mutation):
class PutSurveyDimension(graphene.Mutation):
class Arguments:
input = CreateSurveyDimensionInput(required=True)
input = PutSurveyDimensionInput(required=True)

dimension = graphene.Field(SurveyDimensionType)

@staticmethod
def mutate(
root,
info,
input: CreateSurveyDimensionInput,
input: PutSurveyDimensionInput,
):
survey = Survey.objects.get(event__slug=input.event_slug, slug=input.survey_slug)
form_data: dict[str, str] = input.form_data # type: ignore

# TODO bastardization of graphql_check_access, rethink
graphql_check_access(survey, info, "dimensions", "mutation")

form_data: dict[str, str] = input.form_data # type: ignore
print(form_data)
if input.dimension_slug is None:
if survey.dimensions.filter(slug=form_data["slug"]).exists():
raise ValueError("Dimension with this slug already exists")
else:
form_data["slug"] = input.dimension_slug # type: ignore

dimension = DimensionDTO.from_form_data(form_data).save(survey)

return CreateSurveyDimension(dimension=dimension) # type: ignore
return PutSurveyDimension(dimension=dimension) # type: ignore
47 changes: 47 additions & 0 deletions backend/forms/graphql/mutations/put_survey_dimension_value.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import graphene
from graphene.types.generic import GenericScalar

from access.cbac import graphql_check_access

from ...models.dimension import DimensionValueDTO
from ...models.survey import Survey
from ..dimension import SurveyDimensionValueType


class PutSurveyDimensionValueInput(graphene.InputObjectType):
event_slug = graphene.String(required=True)
survey_slug = graphene.String(required=True)
dimension_slug = graphene.String(required=True)
value_slug = graphene.String(description="If set, update existing; otherwise, create new")
form_data = GenericScalar(required=True)


class PutSurveyDimensionValue(graphene.Mutation):
class Arguments:
input = PutSurveyDimensionValueInput(required=True)

value = graphene.Field(SurveyDimensionValueType)

@staticmethod
def mutate(
root,
info,
input: PutSurveyDimensionValueInput,
):
survey = Survey.objects.get(event__slug=input.event_slug, slug=input.survey_slug)
form_data: dict[str, str] = input.form_data # type: ignore

# TODO bastardization of graphql_check_access, rethink
graphql_check_access(survey, info, "dimensions", "mutation")

dimension = survey.dimensions.get(slug=input.dimension_slug)

if input.value_slug is None:
if survey.dimensions.filter(slug=form_data["slug"]).exists():
raise ValueError("Dimension value with this slug already exists")
else:
form_data["slug"] = input.value_slug # type: ignore

value = DimensionValueDTO.from_form_data(form_data).save(dimension)

return PutSurveyDimensionValue(value=value) # type: ignore
55 changes: 42 additions & 13 deletions backend/forms/models/dimension.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import logging
from typing import Self

import pydantic
from django.db import models
Expand Down Expand Up @@ -49,6 +50,10 @@ class Dimension(models.Model):
def event(self) -> Event:
return self.survey.event

@property
def can_remove(self) -> bool:
return not ResponseDimensionValue.objects.filter(dimension=self).exists()

def get_choices(self, language: str | None = None) -> list[Choice]:
return [
Choice(
Expand Down Expand Up @@ -97,19 +102,35 @@ def event(self) -> Event:
def survey(self) -> Survey:
return self.dimension.survey

@property
def can_remove(self) -> bool:
return not ResponseDimensionValue.objects.filter(value=self).exists()

class Meta:
unique_together = ("dimension", "slug")


def unpack_localized_value(form_data: dict[str, str], field_name: str) -> dict[str, str | dict[str, str]]:
"""
In some forms, localized values are splat into multiple fields title.fi, title.en etc.
Database expects them as a single JSON field. Do the needful.
"""
prefix = f"{field_name}."
title = {key.removeprefix(prefix): value for key, value in form_data.items() if key.startswith(prefix)}
data: dict[str, str | dict[str, str]] = {
key: value for key, value in form_data.items() if not key.startswith(prefix)
}
data["title"] = title
return data


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

def save(self, dimension: Dimension):
# TODO(#386) change to get_or_create when form editor is implemented
# so that these can be loaded once but user changes are not overwritten
DimensionValue.objects.update_or_create(
dimension=dimension,
slug=self.slug,
Expand All @@ -120,6 +141,16 @@ def save(self, dimension: Dimension):
),
)

@classmethod
def from_form_data(cls, form_data: dict[str, str]) -> Self:
"""
Used by dimension editor to create and edit dimension values.
The localized title field is presented as field per language.
"""
data = unpack_localized_value(form_data, "title")

return cls.model_validate(data)


class DimensionDTO(pydantic.BaseModel):
"""
Expand All @@ -128,16 +159,14 @@ class DimensionDTO(pydantic.BaseModel):

model_config = pydantic.ConfigDict(populate_by_name=True)

slug: str
slug: str = pydantic.Field(min_length=1)
title: dict[str, str]
choices: list[DimensionValueDTO] = pydantic.Field(default_factory=list)
choices: list[DimensionValueDTO] | None = pydantic.Field(default=None)
is_key_dimension: bool = pydantic.Field(default=False, alias="isKeyDimension")
is_multi_value: bool = pydantic.Field(default=False, alias="isMultiValue")
is_shown_to_respondent: bool = pydantic.Field(default=False, alias="isShownToRespondent")

def save(self, survey: Survey, order: int = 0):
# TODO(#386) change to get_or_create when form editor is implemented
# so that these can be loaded once but user changes are not overwritten
dimension, _created = Dimension.objects.update_or_create(
survey=survey,
slug=self.slug,
Expand All @@ -150,6 +179,9 @@ def save(self, survey: Survey, order: int = 0):
),
)

if self.choices is None:
return dimension

# delete choices that are no longer present
if not _created:
DimensionValue.objects.filter(dimension=dimension).exclude(
Expand All @@ -162,23 +194,20 @@ def save(self, survey: Survey, order: int = 0):
return dimension

@classmethod
def save_many(cls, survey: Survey, dimensions: list[DimensionDTO]):
def save_many(cls, survey: Survey, dimensions: list[Self]):
# TODO(perf) bulk save & refresh once
order = 0
for dimension in dimensions:
order += 10
dimension.save(survey, order)

@classmethod
def from_form_data(cls, form_data: dict[str, str]) -> DimensionDTO:
def from_form_data(cls, form_data: dict[str, str]) -> Self:
"""
Used by dimension editor to create and edit dimensions.
The localized title field is presented as field per language.
"""
title = {key.removeprefix("title."): value for key, value in form_data.items() if key.startswith("title.")}
data: dict[str, str | dict[str, str]] = {
key: value for key, value in form_data.items() if not key.startswith("title.")
}
data["title"] = title
data = unpack_localized_value(form_data, "title")

return cls.model_validate(data)

Expand Down
9 changes: 5 additions & 4 deletions backend/forms/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from core.models import Event

from .excel_export import get_header_cells, get_response_cells
from .graphql.mutations.create_survey_dimension import CreateSurveyDimension
from .graphql.mutations.put_survey_dimension import PutSurveyDimension
from .graphql.mutations.update_response_dimensions import UpdateResponseDimensions
from .models.dimension import Dimension, DimensionValue
from .models.field import Choice, Field, FieldType
Expand Down Expand Up @@ -697,8 +697,8 @@ def test_lift_and_set_dimensions(_patched_graphql_check_access):


@pytest.mark.django_db
@mock.patch("forms.graphql.mutations.create_survey_dimension.graphql_check_access", autospec=True)
def test_create_survey_dimension(_patched_graphql_check_access):
@mock.patch("forms.graphql.mutations.put_survey_dimension.graphql_check_access", autospec=True)
def test_put_survey_dimension(_patched_graphql_check_access):
form_data = {
"slug": "test-dimension",
"title.en": "Test dimension",
Expand All @@ -713,13 +713,14 @@ def test_create_survey_dimension(_patched_graphql_check_access):
slug="test-survey",
)

CreateSurveyDimension.mutate(
PutSurveyDimension.mutate(
None,
MOCK_INFO,
SimpleNamespace(
event_slug=event.slug,
survey_slug=survey.slug,
form_data=form_data,
dimension_slug=None,
), # type: ignore
)

Expand Down
8 changes: 6 additions & 2 deletions backend/graphql_api/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
from core.graphql.event import FullEventType
from core.graphql.profile import ProfileType
from core.models import Event, Person
from forms.graphql.mutations.create_survey_dimension import CreateSurveyDimension
from forms.graphql.mutations.create_survey_response import CreateSurveyResponse
from forms.graphql.mutations.delete_survey_dimension import DeleteSurveyDimension
from forms.graphql.mutations.delete_survey_dimension_value import DeleteSurveyDimensionValue
from forms.graphql.mutations.init_file_upload import InitFileUpload
from forms.graphql.mutations.put_survey_dimension import PutSurveyDimension
from forms.graphql.mutations.put_survey_dimension_value import PutSurveyDimensionValue
from forms.graphql.mutations.update_response_dimensions import UpdateResponseDimensions


Expand Down Expand Up @@ -61,9 +63,11 @@ def resolve_profile(root, info):


class Mutation(graphene.ObjectType):
create_survey_dimension = CreateSurveyDimension.Field()
put_survey_dimension = PutSurveyDimension.Field()
put_survey_dimension_value = PutSurveyDimensionValue.Field()
create_survey_response = CreateSurveyResponse.Field()
delete_survey_dimension = DeleteSurveyDimension.Field()
delete_survey_dimension_value = DeleteSurveyDimensionValue.Field()
init_file_upload = InitFileUpload.Field()
update_response_dimensions = UpdateResponseDimensions.Field()

Expand Down
3 changes: 2 additions & 1 deletion backend/program_v2/models/dimension.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@


logger = logging.getLogger("kompassi")
DIMENSION_SLUG_MAX_LENGTH = 255


class Dimension(models.Model):
event = models.ForeignKey("core.Event", on_delete=models.CASCADE, related_name="dimensions")
slug = models.CharField(max_length=255, validators=[validate_slug])
slug = models.CharField(max_length=DIMENSION_SLUG_MAX_LENGTH, validators=[validate_slug])
title = HStoreField(blank=True, default=dict)
color = models.CharField(max_length=63, blank=True, default="")
icon = models.FileField(upload_to="program_v2/dimension_icons", blank=True)
Expand Down
Loading

0 comments on commit 5d5d65c

Please sign in to comment.