Skip to content

Commit c1a3588

Browse files
committed
feat(program_v2): accept program offer
1 parent 6ba23e2 commit c1a3588

File tree

16 files changed

+341
-54
lines changed

16 files changed

+341
-54
lines changed

backend/core/utils/model_utils.py

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import re
2-
from typing import Literal
2+
from collections.abc import Callable, Iterable
3+
from typing import Any, Literal
34

45
import phonenumbers
56
from django.conf import settings
@@ -36,19 +37,15 @@ def make_slug_field(
3637
unique=True,
3738
separator: Literal["-", "_"] = "-",
3839
verbose_name: str | None = None,
39-
help_text: str | None = None,
40+
help_text: str = "",
41+
extra_validators: Iterable[Callable[[Any], None]] | None = None,
4042
):
4143
if verbose_name is None:
42-
verbose_name = _("Slug")
44+
verbose_name = "Slug"
4345

44-
if help_text is None:
45-
help_text = _(
46-
"The slug is a URL-friendly identifier. "
47-
"It may only contain lowercase letters, numbers, and hyphens. "
48-
"It may not be changed after creation."
49-
)
50-
51-
validators = [validate_slug] if separator == "-" else [validate_slug_underscore]
46+
validators: list[Callable[[Any], None]] = [validate_slug] if separator == "-" else [validate_slug_underscore]
47+
if extra_validators is not None:
48+
validators.extend(extra_validators)
5249

5350
return models.CharField(
5451
max_length=255,

backend/dimensions/models/dimension.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import TYPE_CHECKING
55

66
from django.contrib import admin
7+
from django.core.exceptions import ValidationError
78
from django.db import models
89
from django.http import HttpRequest
910
from django.utils.translation import get_language
@@ -25,6 +26,28 @@
2526
logger = logging.getLogger("kompassi")
2627

2728

29+
INVALID_DIMENSION_SLUGS = [
30+
# clash with field names in Program
31+
"slug",
32+
"title",
33+
"description",
34+
"annotations",
35+
# clash with query string parameters for ProgramFilters
36+
"favorited",
37+
"past",
38+
"display",
39+
"search",
40+
]
41+
42+
43+
def invalid_slugs_validator(value: str) -> None:
44+
"""
45+
Validator for dimension slugs. Raises a ValidationError if the slug is invalid.
46+
"""
47+
if value in INVALID_DIMENSION_SLUGS:
48+
raise ValidationError(f"{value!r} is a reserved word that cannot be used as a dimension slug.")
49+
50+
2851
class Dimension(models.Model):
2952
"""
3053
Dimensions are "multiple choice fields on steroids" that can be used to
@@ -81,7 +104,7 @@ class Dimension(models.Model):
81104
),
82105
)
83106

84-
slug = make_slug_field(unique=False, separator="_")
107+
slug = make_slug_field(unique=False, separator="_", extra_validators=[invalid_slugs_validator])
85108

86109
# NOTE SUPPORTED_LANGUAGES
87110
title_en = models.TextField(blank=True, default="")

backend/dimensions/utils/__init__.py

Whitespace-only changes.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from forms.models.field import Field, FieldType
2+
from forms.utils.process_form_data import process_form_data
3+
4+
from ..models.dimension import Dimension
5+
6+
7+
def process_dimensions_form(
8+
dimensions: list[Dimension],
9+
form_data: dict[str, str],
10+
) -> dict[str, list[str]]:
11+
"""
12+
When values for dimensions are selected using a SchemaForm, this function processes the form data
13+
and returns the values for each dimension.
14+
15+
Args:
16+
dimensions: A list of Dimension objects to process.
17+
form_data: A dictionary of form data to process.
18+
19+
Returns:
20+
A dictionary containing the value slugs for each dimension present in dimensions.
21+
"""
22+
fields_single = [Field.from_dimension(dimension, FieldType.SINGLE_SELECT) for dimension in dimensions]
23+
fields_multi = [Field.from_dimension(dimension, FieldType.MULTI_SELECT) for dimension in dimensions]
24+
25+
values_single, warnings_single = process_form_data(fields_single, form_data)
26+
if warnings_single:
27+
raise ValueError(warnings_single)
28+
29+
values_multi, warnings_multi = process_form_data(fields_multi, form_data)
30+
if warnings_multi:
31+
raise ValueError(warnings_multi)
32+
33+
values: dict[str, list[str]] = {k: [v] for k, v in values_single.items() if v}
34+
for k, v in values_multi.items():
35+
values.setdefault(k, []).extend(v)
36+
37+
return values

backend/forms/graphql/mutations/update_response_dimensions.py

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@
22
from graphene.types.generic import GenericScalar
33

44
from access.cbac import graphql_check_instance
5+
from dimensions.utils.process_dimensions_form import process_dimensions_form
56

6-
from ...models.field import Field, FieldType
77
from ...models.survey import Survey
8-
from ...utils.process_form_data import process_form_data
98
from ..response import FullResponseType
109

1110

@@ -49,21 +48,7 @@ def mutate(
4948
operation="update",
5049
)
5150

52-
fields_single = [Field.from_dimension(dimension, FieldType.SINGLE_SELECT) for dimension in dimensions]
53-
fields_multi = [Field.from_dimension(dimension, FieldType.MULTI_SELECT) for dimension in dimensions]
54-
55-
values_single, warnings_single = process_form_data(fields_single, form_data)
56-
if warnings_single:
57-
raise ValueError(warnings_single)
58-
59-
values_multi, warnings_multi = process_form_data(fields_multi, form_data)
60-
if warnings_multi:
61-
raise ValueError(warnings_multi)
62-
63-
values: dict[str, list[str]] = {k: [v] for k, v in values_single.items() if v}
64-
for k, v in values_multi.items():
65-
values.setdefault(k, []).extend(v)
66-
51+
values = process_dimensions_form(dimensions, form_data)
6752
response.set_dimension_values(values)
6853

6954
return UpdateResponseDimensions(response=response) # type: ignore

backend/graphql_api/schema.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from forms.graphql.mutations.update_form_fields import UpdateFormFields
2323
from forms.graphql.mutations.update_response_dimensions import UpdateResponseDimensions
2424
from forms.graphql.mutations.update_survey import UpdateSurvey
25+
from program_v2.graphql.mutations.accept_program_offer import AcceptProgramOffer
2526
from program_v2.graphql.mutations.create_program_form import CreateProgramForm
2627
from program_v2.graphql.mutations.favorites import (
2728
MarkProgramAsFavorite,
@@ -126,6 +127,7 @@ class Mutation(graphene.ObjectType):
126127
create_program_form = CreateProgramForm.Field()
127128
update_program_form = UpdateProgramForm.Field()
128129

130+
accept_program_offer = AcceptProgramOffer.Field()
129131
update_program = UpdateProgram.Field()
130132

131133
# Tickets v2
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import graphene
2+
from django.db import transaction
3+
from graphene.types.generic import GenericScalar
4+
5+
from access.cbac import graphql_check_instance, graphql_check_model
6+
from core.models.event import Event
7+
from dimensions.utils.process_dimensions_form import process_dimensions_form
8+
from forms.models.field import Field, FieldType
9+
from forms.models.response import Response
10+
from forms.utils.process_form_data import process_form_data
11+
12+
from ...models.program import Program
13+
from ..program_full import FullProgramType
14+
15+
# TODO supply system forms to the frontend from the backend
16+
ACCEPT_PROGRAM_FORM_FIELDS = [
17+
Field(
18+
slug="slug",
19+
type=FieldType.SINGLE_LINE_TEXT,
20+
required=True,
21+
),
22+
Field(
23+
slug="title",
24+
type=FieldType.SINGLE_LINE_TEXT,
25+
required=False,
26+
),
27+
# dimensions are added dynamically
28+
]
29+
30+
31+
class AcceptProgramOfferInput(graphene.InputObjectType):
32+
event_slug = graphene.String(required=True)
33+
response_id = graphene.UUID(required=True)
34+
form_data = GenericScalar(required=True)
35+
36+
37+
class AcceptProgramOffer(graphene.Mutation):
38+
class Arguments:
39+
input = AcceptProgramOfferInput(required=True)
40+
41+
program = graphene.NonNull(FullProgramType)
42+
43+
@transaction.atomic
44+
@staticmethod
45+
def mutate(_root, info, input: AcceptProgramOfferInput):
46+
"""
47+
Turns a program offer into a program.
48+
"""
49+
form_data: dict[str, str] = input.form_data # type: ignore
50+
51+
program_offer = Response.objects.get(
52+
id=input.response_id,
53+
form__event__slug=input.event_slug,
54+
form__survey__app="program_v2",
55+
)
56+
event: Event = program_offer.form.event # ugh
57+
58+
# check that we are both allowed to read the program offer and create a program
59+
graphql_check_instance(
60+
program_offer,
61+
info,
62+
app="program_v2",
63+
)
64+
graphql_check_model(
65+
Program,
66+
event.scope,
67+
info,
68+
operation="create",
69+
)
70+
71+
# NOTE form_data is the form sent when accepting the program offer
72+
# and not the form sent when creating the program offer
73+
values, warnings = process_form_data(ACCEPT_PROGRAM_FORM_FIELDS, form_data)
74+
if warnings:
75+
raise ValueError(warnings)
76+
77+
dimension_values = process_dimensions_form(
78+
list(event.program_universe.dimensions.all()),
79+
form_data,
80+
)
81+
program_offer.set_dimension_values(dimension_values)
82+
83+
program = Program.from_program_offer(
84+
program_offer,
85+
slug=values["slug"],
86+
title=values.get("title", ""),
87+
)
88+
program.set_dimension_values(dimension_values)
89+
program.refresh_cached_fields()
90+
91+
return AcceptProgramOffer(program=program) # type: ignore

backend/program_v2/models/annotations.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44

55
from pydantic import BaseModel, Field
66

7-
# from programme.models.programme import Programme
8-
97

108
class AnnotationDataType(Enum):
119
STRING = "string"
@@ -224,3 +222,18 @@ class ProgramAnnotation(BaseModel):
224222
),
225223
),
226224
]
225+
226+
227+
def extract_annotations(values: dict[str, Any], annotations=ANNOTATIONS) -> dict[str, Any]:
228+
"""
229+
Extract known annotations from processed form data.
230+
231+
Args:
232+
values: A dictionary of values to extract annotations from.
233+
annotations: A list of AnnotationSchemoid objects to use for extraction.
234+
235+
Returns:
236+
A dictionary containing the extracted annotations.
237+
"""
238+
# TODO typecheck against ann.type
239+
return {ann.slug: value for ann in annotations if (value := values.get(ann.slug)) is not None}

backend/program_v2/models/program.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@
1212
from django.utils.timezone import now
1313

1414
from core.models import Event
15-
from core.utils import validate_slug
15+
from core.utils.model_utils import slugify, validate_slug
1616
from dimensions.models.scope import Scope
17+
from forms.models.response import Response
1718
from graphql_api.language import SUPPORTED_LANGUAGES, getattr_message_in_language
1819

20+
from .annotations import extract_annotations
21+
1922
if TYPE_CHECKING:
2023
from programme.models.programme import Programme
2124

@@ -41,7 +44,7 @@ class Program(models.Model):
4144
updated_at = models.DateTimeField(auto_now=True)
4245

4346
# denormalized fields
44-
cached_dimensions = models.JSONField(default=dict)
47+
cached_dimensions = models.JSONField(default=dict, blank=True)
4548
cached_earliest_start_time = models.DateTimeField(
4649
null=True,
4750
blank=True,
@@ -308,3 +311,40 @@ def set_dimension_values(self, values_to_set: dict[str, list[str]]):
308311

309312
bulk_delete.delete()
310313
ProgramDimensionValue.objects.bulk_create(bulk_create)
314+
315+
@classmethod
316+
def from_program_offer(
317+
cls,
318+
program_offer: Response,
319+
slug: str = "",
320+
title: str = "",
321+
) -> Self:
322+
"""
323+
Return an unsaved Program instance from a program offer.
324+
"""
325+
values, warnings = program_offer.get_processed_form_data()
326+
if warnings:
327+
logger.warning("Program offer %s had form data warnings: %s", program_offer.id, warnings)
328+
329+
annotations = extract_annotations(values)
330+
331+
if not title:
332+
title = values.get("title", "")
333+
334+
if not slug:
335+
slug = slugify(title)
336+
337+
program = cls(
338+
event=program_offer.event,
339+
slug=slug,
340+
title=title,
341+
description=values.get("description", ""),
342+
annotations=annotations,
343+
created_by=program_offer.created_by,
344+
cached_dimensions={},
345+
)
346+
347+
program.full_clean()
348+
program.save()
349+
350+
return program

frontend/src/__generated__/gql.ts

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)