Skip to content

Commit 5f74ad7

Browse files
hc-sousacursoragent
andcommitted
feat(marketplace): user-suggested categories on provider create
Accept category_name as an alternative to category_slug when listing a service. Names are validated (2–80 chars, at least one letter), deduped against existing slug/name, and stored with user_suggested=True for admin review. Serializer rejects both fields at once. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c61d975 commit 5f74ad7

8 files changed

Lines changed: 206 additions & 8 deletions

File tree

src/marketplace/admin.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ def _reject(modeladmin, request, queryset):
1919

2020
@admin.register(ServiceCategory)
2121
class ServiceCategoryAdmin(admin.ModelAdmin):
22-
list_display = ('name', 'slug', 'island', 'icon')
23-
list_filter = ('island',)
22+
list_display = ('name', 'slug', 'user_suggested', 'island', 'icon')
23+
list_filter = ('island', 'user_suggested')
2424
search_fields = ('name', 'slug')
2525
prepopulated_fields = {'slug': ('name',)}
2626

src/marketplace/api_v3.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,17 @@ def providers_view(request: Request) -> Response:
9191
session_id = data['session_id'].strip()
9292
if not session_id:
9393
return _error('session_required', 'session_id is required', status.HTTP_400_BAD_REQUEST)
94-
if not data.get('name') or not data.get('category_slug'):
95-
return _error('validation_error', 'name and category_slug are required', status.HTTP_400_BAD_REQUEST)
94+
name = (data.get('name') or '').strip()
95+
category_slug = (data.get('category_slug') or '').strip()
96+
category_name = (data.get('category_name') or '').strip()
97+
if not name:
98+
return _error('validation_error', 'name is required', status.HTTP_400_BAD_REQUEST)
99+
if not category_slug and not category_name:
100+
return _error(
101+
'validation_error',
102+
'category_slug or category_name is required',
103+
status.HTTP_400_BAD_REQUEST,
104+
)
96105

97106
session_hash = hash_session_id(session_id, request.island.key)
98107
with for_island(request.island):
@@ -102,6 +111,12 @@ def providers_view(request: Request) -> Response:
102111
)
103112
except services.CategoryNotFound:
104113
return _error('invalid_category', 'Unknown category', status.HTTP_400_BAD_REQUEST)
114+
except services.InvalidCategoryName:
115+
return _error(
116+
'invalid_category_name',
117+
'Invalid category name (2–80 characters, at least one letter)',
118+
status.HTTP_400_BAD_REQUEST,
119+
)
105120
return Response(payload, status=status.HTTP_201_CREATED)
106121

107122

@@ -152,6 +167,12 @@ def provider_detail_view(request: Request, provider_id: int) -> Response:
152167
return _error('not_owner', 'Not allowed', status.HTTP_403_FORBIDDEN)
153168
except services.CategoryNotFound:
154169
return _error('invalid_category', 'Unknown category', status.HTTP_400_BAD_REQUEST)
170+
except services.InvalidCategoryName:
171+
return _error(
172+
'invalid_category_name',
173+
'Invalid category name (2–80 characters, at least one letter)',
174+
status.HTTP_400_BAD_REQUEST,
175+
)
155176
if payload is None:
156177
return _error('not_found', 'Provider not found', status.HTTP_404_NOT_FOUND)
157178
return Response(payload)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 5.0.6 on 2026-06-02 15:37
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("marketplace", "0002_seed_default_categories"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="servicecategory",
15+
name="user_suggested",
16+
field=models.BooleanField(
17+
default=False,
18+
help_text="Created by a user when listing a service; review name/slug in admin.",
19+
),
20+
),
21+
]

src/marketplace/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ class ServiceCategory(TenantScopedModel):
4747
name = models.CharField(max_length=80)
4848
slug = models.SlugField(max_length=80)
4949
icon = models.CharField(max_length=64, blank=True, default='')
50+
user_suggested = models.BooleanField(
51+
default=False,
52+
help_text='Created by a user when listing a service; review name/slug in admin.',
53+
)
5054

5155
class Meta:
5256
ordering = ['name']

src/marketplace/serializers.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
class ProviderWriteSerializer(serializers.Serializer):
99
session_id = serializers.CharField(max_length=128)
1010
name = serializers.CharField(max_length=160, required=False)
11-
category_slug = serializers.SlugField(required=False)
11+
category_slug = serializers.SlugField(required=False, allow_blank=True)
12+
category_name = serializers.CharField(max_length=80, required=False, allow_blank=True)
1213
bio = serializers.CharField(required=False, allow_blank=True)
1314
hourly_rate = serializers.DecimalField(
1415
max_digits=8, decimal_places=2, required=False, allow_null=True
@@ -19,6 +20,19 @@ class ProviderWriteSerializer(serializers.Serializer):
1920
latitude = serializers.FloatField(required=False, allow_null=True)
2021
longitude = serializers.FloatField(required=False, allow_null=True)
2122

23+
def validate(self, attrs: dict) -> dict:
24+
slug = (attrs.get('category_slug') or '').strip()
25+
name = (attrs.get('category_name') or '').strip()
26+
if slug and name:
27+
raise serializers.ValidationError(
28+
'Provide category_slug or category_name, not both.'
29+
)
30+
if slug:
31+
attrs['category_slug'] = slug
32+
if name:
33+
attrs['category_name'] = name
34+
return attrs
35+
2236

2337
class ReviewWriteSerializer(serializers.Serializer):
2438
session_id = serializers.CharField(max_length=128)

src/marketplace/services.py

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from typing import Any
1414

1515
from django.db.models import Avg, Count
16+
from django.utils.text import slugify
1617

1718
from marketplace.models import Review, ServiceCategory, ServiceProvider
1819

@@ -33,6 +34,14 @@ class CategoryNotFound(MarketplaceError):
3334
"""Raised when a write references an unknown category slug."""
3435

3536

37+
class InvalidCategoryName(MarketplaceError):
38+
"""Raised when a suggested category name fails validation."""
39+
40+
41+
_CATEGORY_NAME_MIN = 2
42+
_CATEGORY_NAME_MAX = 80
43+
44+
3645
# --------------------------------------------------------------------------- #
3746
# Serialization
3847
# --------------------------------------------------------------------------- #
@@ -43,6 +52,7 @@ def serialize_category(category: ServiceCategory) -> dict[str, Any]:
4352
'name': category.name,
4453
'slug': category.slug,
4554
'icon': category.icon,
55+
'userSuggested': category.user_suggested,
4656
}
4757

4858

@@ -97,6 +107,48 @@ def _resolve_category(slug: str) -> ServiceCategory:
97107
raise CategoryNotFound(slug) from exc
98108

99109

110+
def _normalize_category_name(name: str) -> str:
111+
cleaned = ' '.join(name.split())
112+
if len(cleaned) < _CATEGORY_NAME_MIN or len(cleaned) > _CATEGORY_NAME_MAX:
113+
raise InvalidCategoryName('length')
114+
if not any(ch.isalpha() for ch in cleaned):
115+
raise InvalidCategoryName('letters')
116+
slug = slugify(cleaned)[: _CATEGORY_NAME_MAX]
117+
if not slug:
118+
raise InvalidCategoryName('slug')
119+
return cleaned
120+
121+
122+
def get_or_create_category_by_name(*, island, name: str) -> ServiceCategory:
123+
"""Resolve an existing category or create a user-suggested one."""
124+
cleaned = _normalize_category_name(name)
125+
slug = slugify(cleaned)[: _CATEGORY_NAME_MAX]
126+
existing = ServiceCategory.objects.filter(island=island, slug=slug).first()
127+
if existing:
128+
return existing
129+
existing = ServiceCategory.objects.filter(island=island, name__iexact=cleaned).first()
130+
if existing:
131+
return existing
132+
return ServiceCategory.objects.create(
133+
island=island,
134+
name=cleaned,
135+
slug=slug,
136+
user_suggested=True,
137+
)
138+
139+
140+
def _resolve_category_from_write(*, island, data: dict[str, Any]) -> ServiceCategory:
141+
slug = (data.get('category_slug') or '').strip()
142+
name = (data.get('category_name') or '').strip()
143+
if slug and name:
144+
raise InvalidCategoryName('both')
145+
if slug:
146+
return _resolve_category(slug)
147+
if name:
148+
return get_or_create_category_by_name(island=island, name=name)
149+
raise CategoryNotFound('')
150+
151+
100152
# --------------------------------------------------------------------------- #
101153
# Providers — reads
102154
# --------------------------------------------------------------------------- #
@@ -180,15 +232,20 @@ def get_provider(
180232

181233

182234
def _apply_provider_fields(provider: ServiceProvider, data: dict[str, Any]) -> None:
183-
if 'category_slug' in data and data['category_slug']:
184-
provider.category = _resolve_category(data['category_slug'])
235+
slug = (data.get('category_slug') or '').strip() if 'category_slug' in data else ''
236+
name = (data.get('category_name') or '').strip() if 'category_name' in data else ''
237+
if slug or name:
238+
provider.category = _resolve_category_from_write(
239+
island=provider.island,
240+
data={'category_slug': slug, 'category_name': name},
241+
)
185242
for field in _PROVIDER_WRITE_FIELDS:
186243
if field in data:
187244
setattr(provider, field, data[field])
188245

189246

190247
def create_provider(*, island, session_hash: str, data: dict[str, Any]) -> dict[str, Any]:
191-
category = _resolve_category(data.get('category_slug', ''))
248+
category = _resolve_category_from_write(island=island, data=data)
192249
provider = ServiceProvider(
193250
island=island,
194251
category=category,

src/marketplace/tests/test_api_v3.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,49 @@ def test_create_unknown_category(self):
9595
self.assertEqual(resp.status_code, 400)
9696
self.assertEqual(resp.json()['error']['code'], 'invalid_category')
9797

98+
def test_create_with_category_name(self):
99+
resp = self.client.post(
100+
'/api/v3/marketplace/providers',
101+
{
102+
'session_id': 'owner',
103+
'name': 'Rex Walks',
104+
'category_name': 'Dog Walking',
105+
},
106+
format='json',
107+
**SM,
108+
)
109+
self.assertEqual(resp.status_code, 201)
110+
self.assertEqual(resp.json()['category']['slug'], 'dog-walking')
111+
cats = self.client.get('/api/v3/marketplace/categories', **SM).json()['categories']
112+
slugs = [c['slug'] for c in cats]
113+
self.assertIn('dog-walking', slugs)
114+
cat = next(c for c in cats if c['slug'] == 'dog-walking')
115+
self.assertTrue(cat['userSuggested'])
116+
117+
def test_create_rejects_both_category_fields(self):
118+
resp = self.client.post(
119+
'/api/v3/marketplace/providers',
120+
{
121+
'session_id': 's',
122+
'name': 'X',
123+
'category_slug': 'electricians',
124+
'category_name': 'Other',
125+
},
126+
format='json',
127+
**SM,
128+
)
129+
self.assertEqual(resp.status_code, 400)
130+
131+
def test_create_rejects_invalid_category_name(self):
132+
resp = self.client.post(
133+
'/api/v3/marketplace/providers',
134+
{'session_id': 's', 'name': 'X', 'category_name': '!!'},
135+
format='json',
136+
**SM,
137+
)
138+
self.assertEqual(resp.status_code, 400)
139+
self.assertEqual(resp.json()['error']['code'], 'invalid_category_name')
140+
98141
# --- ownership ----------------------------------------------------------
99142

100143
def test_patch_non_owner_forbidden(self):

src/marketplace/tests/test_services.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,44 @@ def test_create_unknown_category_raises(self):
4444
data={'category_slug': 'nope', 'name': 'X'},
4545
)
4646

47+
def test_create_with_new_category_name(self):
48+
result = services.create_provider(
49+
island=self.island,
50+
session_hash='owner',
51+
data={'category_name': 'Dog Walking', 'name': 'Rex Walks'},
52+
)
53+
cat = ServiceCategory.objects.get(slug='dog-walking')
54+
self.assertTrue(cat.user_suggested)
55+
self.assertEqual(result['category']['slug'], 'dog-walking')
56+
57+
def test_create_reuses_existing_category_by_similar_name(self):
58+
services.create_provider(
59+
island=self.island,
60+
session_hash='a',
61+
data={'category_name': 'Dog Walking', 'name': 'A'},
62+
)
63+
before = ServiceCategory.objects.filter(slug='dog-walking').count()
64+
services.create_provider(
65+
island=self.island,
66+
session_hash='b',
67+
data={'category_name': 'dog walking', 'name': 'B'},
68+
)
69+
self.assertEqual(ServiceCategory.objects.filter(slug='dog-walking').count(), before)
70+
71+
def test_invalid_category_name_rejected(self):
72+
with self.assertRaises(services.InvalidCategoryName):
73+
services.create_provider(
74+
island=self.island,
75+
session_hash='x',
76+
data={'category_name': '!!', 'name': 'X'},
77+
)
78+
with self.assertRaises(services.InvalidCategoryName):
79+
services.create_provider(
80+
island=self.island,
81+
session_hash='x',
82+
data={'category_slug': 'electricians', 'category_name': 'Other', 'name': 'X'},
83+
)
84+
4785
def test_moderate_invalid_action_raises(self):
4886
result = self._create()
4987
with self.assertRaises(ValueError):

0 commit comments

Comments
 (0)