Skip to content

Commit db51d16

Browse files
committed
feat(tool-picker): implement scoring-based tool recommendation
- Implement scoring-based recommendation logic comparing user and tool answers - Select best matching tool per submission - Add user submission mutation to capture user answers - Add test cases for submission and recommendation flow - Add type hints for related managers to fix static typing issues
1 parent 4de1983 commit db51d16

File tree

19 files changed

+989
-40
lines changed

19 files changed

+989
-40
lines changed

apps/resources/migrations/0001_initial.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 5.2.9 on 2026-01-08 06:17
1+
# Generated by Django 5.2.9 on 2026-02-02 08:15
22

33
import django.db.models.deletion
44
from django.conf import settings

apps/tool_picker/admin.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -330,20 +330,31 @@ def recommendation_count(self, obj: UserSubmission):
330330
return obj.results.count()
331331

332332

333+
@admin.register(CheckboxOption)
334+
class CheckboxOptionAdmin(admin.ModelAdmin):
335+
search_fields = ["id"]
336+
337+
333338
@admin.register(UserAnswer)
334339
class UserAnswerAdmin(admin.ModelAdmin): # type: ignore[reportMissingTypeArgument]
335340
list_display = ["submission_id", "question", "question_type", "get_answer"]
336341
list_filter = ["submission__catalog", "question__question_type"]
337-
search_fields = ["submission__id", "question__title"]
342+
search_fields = ["submission__id", "question__title", "selected_options__id"]
343+
autocomplete_fields = ("selected_options",)
344+
readonly_fields = ("selected_options",)
345+
346+
@typing.override
347+
def get_queryset(self, request): # type: ignore[reportMissingTypeArgument]
348+
return super().get_queryset(request).prefetch_related("selected_options")
338349

339350
def get_fields(self, request, obj=None): # type: ignore[reportMissingTypeArgument]
340351
"""Show only relevant fields based on question type."""
341352
base_fields = ["submission", "question"]
342353

343-
if obj and obj.question.question_type == "ordinal":
354+
if obj and obj.question.question_type == QuestionTypeEnum.ORDINAL.value:
344355
return [*base_fields, "ordinal_value"]
345356

346-
if obj and obj.question.question_type == "checkbox":
357+
if obj and obj.question.question_type == QuestionTypeEnum.CHECKBOX.value:
347358
return [*base_fields, "selected_options"]
348359

349360
return [*base_fields, "ordinal_value", "selected_options"]
@@ -366,7 +377,7 @@ def question_type(self, obj: UserAnswer):
366377

367378
@admin.display(description="Answer")
368379
def get_answer(self, obj: UserAnswer):
369-
if obj.question.question_type == "ordinal":
380+
if obj.question.question_type == QuestionTypeEnum.ORDINAL:
370381
return obj.ordinal_value or "-"
371382
options = obj.selected_options.all()
372383
return ", ".join([opt.text for opt in options]) if options else "(none)"

apps/tool_picker/factories.py

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,82 @@
1-
from factory.declarations import SubFactory
1+
from factory.declarations import Sequence, SubFactory
22
from factory.django import DjangoModelFactory
33

4-
from apps.tool_picker.models import Catalog, Tool
4+
from apps.tool_picker.models import (
5+
Catalog,
6+
CheckboxOption,
7+
Question,
8+
Tool,
9+
ToolAnswer,
10+
)
511
from apps.user.factories import UserFactory
612

713

814
class CatalogFactory(DjangoModelFactory[Catalog]):
9-
class Meta: # type: ignore[misc]
15+
class Meta: # type: ignore[reportMissingTypeArgument]
1016
model = Catalog
1117

18+
name = Sequence(lambda n: f"Catalog {n}")
19+
description = "Catalog description"
20+
show_in_help_me_choose = False
21+
22+
created_by = SubFactory(UserFactory)
23+
modified_by = SubFactory(UserFactory)
24+
25+
26+
class QuestionFactory(DjangoModelFactory[Question]):
27+
class Meta: # type: ignore[reportMissingTypeArgument]
28+
model = Question
29+
30+
catalog = SubFactory(CatalogFactory)
31+
32+
title = Sequence(lambda n: f"Question {n}")
33+
short_name = Sequence(lambda n: f"question_{n}")
34+
description = "Question description"
35+
order = Sequence(lambda n: n + 1)
36+
37+
# Ordinal labels
38+
label_na = "N/A"
39+
label_1 = "1"
40+
label_2 = "2"
41+
label_3 = "3"
42+
label_4 = "4"
43+
44+
created_by = SubFactory(UserFactory)
45+
modified_by = SubFactory(UserFactory)
46+
47+
48+
class CheckboxOptionFactory(DjangoModelFactory[CheckboxOption]):
49+
class Meta: # type: ignore[reportMissingTypeArgument]
50+
model = CheckboxOption
51+
52+
text = Sequence(lambda n: f"Option {n}")
53+
order = Sequence(lambda n: n + 1)
54+
1255
created_by = SubFactory(UserFactory)
1356
modified_by = SubFactory(UserFactory)
1457

1558

1659
class ToolFactory(DjangoModelFactory[Tool]):
17-
class Meta: # type: ignore[misc]
60+
class Meta: # type: ignore[reportMissingTypeArgument]
1861
model = Tool
1962

2063
catalog = SubFactory(CatalogFactory)
64+
65+
name = Sequence(lambda n: f"Tool {n}")
66+
tagline = Sequence(lambda n: f"Tagline {n}")
67+
description = "Tool description"
68+
69+
video_link = None
70+
tool_link = None
71+
logo = None
72+
73+
created_by = SubFactory(UserFactory)
74+
modified_by = SubFactory(UserFactory)
75+
76+
77+
class ToolAnswerFactory(DjangoModelFactory[ToolAnswer]):
78+
class Meta: # type: ignore[reportMissingTypeArgument]
79+
model = ToolAnswer
80+
2181
created_by = SubFactory(UserFactory)
2282
modified_by = SubFactory(UserFactory)

apps/tool_picker/graphql/filters.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import strawberry
22
import strawberry_django
33

4-
from apps.tool_picker.models import Catalog, Tool
4+
from apps.tool_picker.models import Catalog, RecommendationResult, Tool, UserSubmission
55

66

77
@strawberry_django.filters.filter(Catalog, lookups=True)
@@ -14,3 +14,14 @@ class CatalogFilter:
1414
class ToolFilter:
1515
id: strawberry.ID | None
1616
catalog_id: strawberry.auto
17+
18+
19+
@strawberry_django.filters.filter(UserSubmission, lookups=True)
20+
class UserSubmissionFilter:
21+
id: strawberry.ID | None = strawberry.UNSET
22+
23+
24+
@strawberry_django.filters.filter(RecommendationResult, lookups=True)
25+
class RecommendationResultFilter:
26+
id: strawberry.ID | None = strawberry.UNSET
27+
submission: UserSubmissionFilter | None = strawberry.UNSET

apps/tool_picker/graphql/inputs.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import strawberry
2+
3+
4+
@strawberry.input
5+
class UserAnswerInput:
6+
question: strawberry.ID
7+
ordinal_value: int | None = strawberry.UNSET
8+
selected_options: list[strawberry.ID] | None = strawberry.UNSET
9+
10+
11+
@strawberry.input
12+
class UserSubmissionInput:
13+
catalog: strawberry.ID
14+
answers: list[UserAnswerInput]
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import strawberry
2+
import strawberry_django
3+
4+
from apps.tool_picker.graphql.inputs import UserSubmissionInput
5+
from apps.tool_picker.graphql.types import UserSubmissionType
6+
from apps.tool_picker.serializers import UserSubmissionSerializer
7+
from main.graphql.context import Info
8+
from utils.graphql.mutations import ModelMutation
9+
from utils.graphql.types import MutationResponseType
10+
11+
12+
@strawberry.type
13+
class Mutation:
14+
@strawberry_django.mutation
15+
async def create_user_submission(
16+
self,
17+
info: Info,
18+
data: UserSubmissionInput,
19+
) -> MutationResponseType[UserSubmissionType]:
20+
return await ModelMutation(UserSubmissionSerializer).handle_create_mutation(
21+
data,
22+
info,
23+
None,
24+
)

apps/tool_picker/graphql/orders.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import strawberry
22
import strawberry_django
33

4-
from apps.tool_picker.models import Catalog, Tool
4+
from apps.tool_picker.models import Catalog, RecommendationResult, Tool
55

66

77
@strawberry_django.order_type(Catalog)
@@ -12,3 +12,9 @@ class CatalogOrder:
1212
@strawberry_django.order_type(Tool)
1313
class ToolOrder:
1414
id: strawberry.auto
15+
16+
17+
@strawberry_django.order_type(RecommendationResult)
18+
class RecommendationResultOrder:
19+
id: strawberry.auto
20+
rank: strawberry.auto

apps/tool_picker/graphql/queries.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
import strawberry_django
33
from strawberry_django.pagination import OffsetPaginated
44

5-
from .filters import CatalogFilter, ToolFilter
6-
from .orders import CatalogOrder, ToolOrder
7-
from .types import CatalogType, ToolType
5+
from .filters import CatalogFilter, RecommendationResultFilter, ToolFilter
6+
from .orders import CatalogOrder, RecommendationResultOrder, ToolOrder
7+
from .types import CatalogType, RecommendationResultType, ToolType
88

99

1010
@strawberry.type
@@ -22,3 +22,9 @@ class Query:
2222
)
2323

2424
tool: ToolType = strawberry_django.field()
25+
26+
recommendation_results: OffsetPaginated[RecommendationResultType] = strawberry_django.offset_paginated(
27+
filters=RecommendationResultFilter,
28+
order=RecommendationResultOrder,
29+
)
30+
recommendation_result: RecommendationResultType = strawberry_django.field()

apps/tool_picker/graphql/types.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
Catalog,
66
CheckboxOption,
77
Question,
8+
RecommendationResult,
89
Tool,
910
ToolAnswer,
1011
)
@@ -65,3 +66,18 @@ class ToolType:
6566
tool_link: strawberry.auto
6667
logo: DjangoFileType | None
6768
answers: list[ToolAnswerType]
69+
70+
71+
@strawberry.type
72+
class UserSubmissionType:
73+
id: strawberry.ID
74+
catalog_id: strawberry.ID
75+
76+
77+
@strawberry_django.type(RecommendationResult)
78+
class RecommendationResultType:
79+
id: strawberry.auto
80+
submission: strawberry.auto
81+
rank: strawberry.auto
82+
score: strawberry.auto
83+
tool: ToolType

apps/tool_picker/migrations/0001_initial.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 5.2.9 on 2026-01-08 06:17
1+
# Generated by Django 5.2.9 on 2026-02-02 08:15
22

33
import apps.tool_picker.models
44
import django.core.validators
@@ -26,6 +26,7 @@ class Migration(migrations.Migration):
2626
('modified_at', models.DateTimeField(auto_now=True)),
2727
('name', models.CharField(max_length=200)),
2828
('description', models.TextField()),
29+
('show_in_help_me_choose', models.BooleanField(default=False)),
2930
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL)),
3031
('modified_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL)),
3132
],
@@ -135,7 +136,7 @@ class Migration(migrations.Migration):
135136
('created_at', models.DateTimeField(auto_now_add=True)),
136137
('modified_at', models.DateTimeField(auto_now=True)),
137138
('description', models.TextField()),
138-
('ordinal_value', django_choices_field.fields.IntegerChoicesField(blank=True, choices=[(10, 'n/a'), (11, '1'), (12, '2'), (13, '3'), (14, '4')], choices_enum=apps.tool_picker.models.OrdinalTypeEnum, null=True)),
139+
('ordinal_value', django_choices_field.fields.IntegerChoicesField(blank=True, choices=[(100, 'n/a'), (1, 'One'), (2, 'Two'), (3, 'Three'), (4, 'Four')], choices_enum=apps.tool_picker.models.OrdinalTypeEnum, null=True)),
139140
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL)),
140141
('modified_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL)),
141142
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tool_answers', to='tool_picker.question')),
@@ -154,7 +155,7 @@ class Migration(migrations.Migration):
154155
name='UserAnswer',
155156
fields=[
156157
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
157-
('ordinal_value', django_choices_field.fields.IntegerChoicesField(blank=True, choices=[(10, 'n/a'), (11, '1'), (12, '2'), (13, '3'), (14, '4')], choices_enum=apps.tool_picker.models.OrdinalTypeEnum, null=True)),
158+
('ordinal_value', django_choices_field.fields.IntegerChoicesField(blank=True, choices=[(100, 'n/a'), (1, 'One'), (2, 'Two'), (3, 'Three'), (4, 'Four')], choices_enum=apps.tool_picker.models.OrdinalTypeEnum, null=True)),
158159
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_answers', to='tool_picker.question')),
159160
('selected_options', models.ManyToManyField(blank=True, related_name='user_answers', to='tool_picker.checkboxoption')),
160161
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='tool_picker.usersubmission')),

0 commit comments

Comments
 (0)