Skip to content

Commit c10fbb3

Browse files
authored
Merge pull request #82 from sousa-dev/feat/marketplace-module
feat(marketplace): v3 UGC module — models, services, API, moderation
2 parents f2100de + cb36f2e commit c10fbb3

24 files changed

Lines changed: 1973 additions & 0 deletions

src/marketplace/__init__.py

Whitespace-only changes.

src/marketplace/admin.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from django.contrib import admin
2+
3+
from marketplace.models import Review, ServiceCategory, ServiceProvider
4+
5+
6+
def _publish(modeladmin, request, queryset):
7+
queryset.update(status=ServiceProvider.PUBLISHED)
8+
9+
10+
_publish.short_description = 'Publish selected'
11+
12+
13+
def _reject(modeladmin, request, queryset):
14+
queryset.update(status=ServiceProvider.REJECTED)
15+
16+
17+
_reject.short_description = 'Reject selected'
18+
19+
20+
@admin.register(ServiceCategory)
21+
class ServiceCategoryAdmin(admin.ModelAdmin):
22+
list_display = ('name', 'slug', 'user_suggested', 'island', 'icon')
23+
list_filter = ('island', 'user_suggested')
24+
search_fields = ('name', 'slug')
25+
prepopulated_fields = {'slug': ('name',)}
26+
27+
28+
@admin.register(ServiceProvider)
29+
class ServiceProviderAdmin(admin.ModelAdmin):
30+
list_display = ('name', 'category', 'status', 'is_promoted', 'rating', 'review_count', 'island')
31+
list_filter = ('island', 'status', 'is_promoted', 'category')
32+
search_fields = ('name', 'bio', 'phone', 'email')
33+
actions = [_publish, _reject]
34+
35+
36+
@admin.register(Review)
37+
class ReviewAdmin(admin.ModelAdmin):
38+
list_display = ('provider', 'rating', 'status', 'island', 'created_at')
39+
list_filter = ('island', 'status', 'rating')
40+
search_fields = ('text',)
41+
42+
def save_model(self, request, obj, form, change):
43+
super().save_model(request, obj, form, change)
44+
from marketplace.services import recompute_rating
45+
46+
recompute_rating(obj.provider)

src/marketplace/api_v3.py

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
"""Marketplace v3 API (function views, service-backed, session-owned UGC)."""
2+
3+
from __future__ import annotations
4+
5+
from rest_framework import status
6+
from rest_framework.decorators import (
7+
api_view,
8+
permission_classes,
9+
throttle_classes,
10+
)
11+
from rest_framework.permissions import AllowAny
12+
from rest_framework.request import Request
13+
from rest_framework.response import Response
14+
15+
from consent.services import hash_session_id
16+
from marketplace import services
17+
from marketplace.serializers import (
18+
ModerateSerializer,
19+
ProviderWriteSerializer,
20+
ReviewWriteSerializer,
21+
)
22+
from marketplace.throttling import MarketplaceWriteThrottle
23+
from tenancy.services import for_island
24+
25+
26+
def _require_island(request: Request) -> Response | None:
27+
if request.island is None:
28+
return Response(
29+
{'error': {'code': 'island_required', 'message': 'Island context required'}},
30+
status=status.HTTP_400_BAD_REQUEST,
31+
)
32+
return None
33+
34+
35+
def _error(code: str, message: str, http_status: int) -> Response:
36+
return Response({'error': {'code': code, 'message': message}}, status=http_status)
37+
38+
39+
def _session_id(request: Request) -> str:
40+
return (
41+
request.headers.get('X-Session-Id', '').strip()
42+
or str(request.GET.get('session_id', '')).strip()
43+
)
44+
45+
46+
def _is_staff(request: Request) -> bool:
47+
user = getattr(request, 'user', None)
48+
return bool(user and user.is_authenticated and user.is_staff)
49+
50+
51+
def _write_data(validated: dict) -> dict:
52+
return {k: v for k, v in validated.items() if k != 'session_id'}
53+
54+
55+
@api_view(['GET'])
56+
@permission_classes([AllowAny])
57+
def categories_view(request: Request) -> Response:
58+
err = _require_island(request)
59+
if err:
60+
return err
61+
with for_island(request.island):
62+
return Response({'categories': services.list_categories()})
63+
64+
65+
@api_view(['GET', 'POST'])
66+
@permission_classes([AllowAny])
67+
@throttle_classes([MarketplaceWriteThrottle])
68+
def providers_view(request: Request) -> Response:
69+
err = _require_island(request)
70+
if err:
71+
return err
72+
73+
if request.method == 'GET':
74+
category = request.GET.get('category', '').strip() or None
75+
q = request.GET.get('q', '').strip() or None
76+
lat = _float_or_none(request.GET.get('lat'))
77+
lng = _float_or_none(request.GET.get('lng'))
78+
try:
79+
limit = int(request.GET.get('limit', '50'))
80+
except ValueError:
81+
limit = 50
82+
with for_island(request.island):
83+
providers = services.list_providers(
84+
category=category, q=q, lat=lat, lng=lng, limit=limit
85+
)
86+
return Response({'providers': providers})
87+
88+
serializer = ProviderWriteSerializer(data=request.data)
89+
serializer.is_valid(raise_exception=True)
90+
data = serializer.validated_data
91+
session_id = data['session_id'].strip()
92+
if not session_id:
93+
return _error('session_required', 'session_id is 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+
)
105+
106+
session_hash = hash_session_id(session_id, request.island.key)
107+
with for_island(request.island):
108+
try:
109+
payload = services.create_provider(
110+
island=request.island, session_hash=session_hash, data=_write_data(data)
111+
)
112+
except services.CategoryNotFound:
113+
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+
)
120+
return Response(payload, status=status.HTTP_201_CREATED)
121+
122+
123+
@api_view(['GET', 'PUT', 'PATCH', 'DELETE'])
124+
@permission_classes([AllowAny])
125+
@throttle_classes([MarketplaceWriteThrottle])
126+
def provider_detail_view(request: Request, provider_id: int) -> Response:
127+
err = _require_island(request)
128+
if err:
129+
return err
130+
131+
session_hash = _hash_or_empty(_session_id(request), request)
132+
is_staff = _is_staff(request)
133+
134+
if request.method == 'GET':
135+
with for_island(request.island):
136+
payload = services.get_provider(
137+
provider_id, viewer_session_hash=session_hash, is_staff=is_staff
138+
)
139+
if payload is None:
140+
return _error('not_found', 'Provider not found', status.HTTP_404_NOT_FOUND)
141+
return Response(payload)
142+
143+
if request.method == 'DELETE':
144+
with for_island(request.island):
145+
try:
146+
result = services.soft_delete_provider(
147+
provider_id, session_hash=session_hash, is_staff=is_staff
148+
)
149+
except services.OwnershipError:
150+
return _error('not_owner', 'Not allowed', status.HTTP_403_FORBIDDEN)
151+
if result is None:
152+
return _error('not_found', 'Provider not found', status.HTTP_404_NOT_FOUND)
153+
return Response(status=status.HTTP_204_NO_CONTENT)
154+
155+
# PUT / PATCH
156+
serializer = ProviderWriteSerializer(data=request.data, partial=True)
157+
serializer.is_valid(raise_exception=True)
158+
data = serializer.validated_data
159+
write_session = data.get('session_id', '').strip() or _session_id(request)
160+
write_hash = _hash_or_empty(write_session, request)
161+
with for_island(request.island):
162+
try:
163+
payload = services.update_provider(
164+
provider_id, session_hash=write_hash, is_staff=is_staff, data=_write_data(data)
165+
)
166+
except services.OwnershipError:
167+
return _error('not_owner', 'Not allowed', status.HTTP_403_FORBIDDEN)
168+
except services.CategoryNotFound:
169+
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+
)
176+
if payload is None:
177+
return _error('not_found', 'Provider not found', status.HTTP_404_NOT_FOUND)
178+
return Response(payload)
179+
180+
181+
@api_view(['GET', 'POST'])
182+
@permission_classes([AllowAny])
183+
@throttle_classes([MarketplaceWriteThrottle])
184+
def provider_reviews_view(request: Request, provider_id: int) -> Response:
185+
err = _require_island(request)
186+
if err:
187+
return err
188+
189+
if request.method == 'GET':
190+
with for_island(request.island):
191+
return Response({'reviews': services.list_reviews(provider_id)})
192+
193+
serializer = ReviewWriteSerializer(data=request.data)
194+
serializer.is_valid(raise_exception=True)
195+
data = serializer.validated_data
196+
session_id = data['session_id'].strip()
197+
if not session_id:
198+
return _error('session_required', 'session_id is required', status.HTTP_400_BAD_REQUEST)
199+
session_hash = hash_session_id(session_id, request.island.key)
200+
with for_island(request.island):
201+
result = services.upsert_review(
202+
provider_id=provider_id,
203+
session_hash=session_hash,
204+
rating=data['rating'],
205+
text=data.get('text', ''),
206+
)
207+
if result is None:
208+
return _error('not_found', 'Provider not found', status.HTTP_404_NOT_FOUND)
209+
payload, created = result
210+
return Response(payload, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
211+
212+
213+
@api_view(['PUT', 'PATCH', 'DELETE'])
214+
@permission_classes([AllowAny])
215+
@throttle_classes([MarketplaceWriteThrottle])
216+
def review_detail_view(request: Request, review_id: int) -> Response:
217+
err = _require_island(request)
218+
if err:
219+
return err
220+
221+
is_staff = _is_staff(request)
222+
223+
if request.method == 'DELETE':
224+
session_hash = _hash_or_empty(_session_id(request), request)
225+
with for_island(request.island):
226+
try:
227+
result = services.delete_review(
228+
review_id, session_hash=session_hash, is_staff=is_staff
229+
)
230+
except services.OwnershipError:
231+
return _error('not_owner', 'Not allowed', status.HTTP_403_FORBIDDEN)
232+
if result is None:
233+
return _error('not_found', 'Review not found', status.HTTP_404_NOT_FOUND)
234+
return Response(status=status.HTTP_204_NO_CONTENT)
235+
236+
serializer = ReviewWriteSerializer(data=request.data, partial=True)
237+
serializer.is_valid(raise_exception=True)
238+
data = serializer.validated_data
239+
session_hash = _hash_or_empty(
240+
data.get('session_id', '').strip() or _session_id(request), request
241+
)
242+
with for_island(request.island):
243+
try:
244+
payload = services.update_review(
245+
review_id, session_hash=session_hash, is_staff=is_staff, data=_write_data(data)
246+
)
247+
except services.OwnershipError:
248+
return _error('not_owner', 'Not allowed', status.HTTP_403_FORBIDDEN)
249+
if payload is None:
250+
return _error('not_found', 'Review not found', status.HTTP_404_NOT_FOUND)
251+
return Response(payload)
252+
253+
254+
@api_view(['POST'])
255+
@permission_classes([AllowAny])
256+
def provider_moderate_view(request: Request, provider_id: int) -> Response:
257+
err = _require_island(request)
258+
if err:
259+
return err
260+
if not _is_staff(request):
261+
return _error('not_authorized', 'Staff only', status.HTTP_403_FORBIDDEN)
262+
serializer = ModerateSerializer(data=request.data)
263+
serializer.is_valid(raise_exception=True)
264+
with for_island(request.island):
265+
payload = services.moderate_provider(provider_id, serializer.validated_data['action'])
266+
if payload is None:
267+
return _error('not_found', 'Provider not found', status.HTTP_404_NOT_FOUND)
268+
return Response(payload)
269+
270+
271+
@api_view(['POST'])
272+
@permission_classes([AllowAny])
273+
def review_moderate_view(request: Request, review_id: int) -> Response:
274+
err = _require_island(request)
275+
if err:
276+
return err
277+
if not _is_staff(request):
278+
return _error('not_authorized', 'Staff only', status.HTTP_403_FORBIDDEN)
279+
serializer = ModerateSerializer(data=request.data)
280+
serializer.is_valid(raise_exception=True)
281+
with for_island(request.island):
282+
payload = services.moderate_review(review_id, serializer.validated_data['action'])
283+
if payload is None:
284+
return _error('not_found', 'Review not found', status.HTTP_404_NOT_FOUND)
285+
return Response(payload)
286+
287+
288+
def _float_or_none(raw: str | None) -> float | None:
289+
if raw is None or str(raw).strip() == '':
290+
return None
291+
try:
292+
return float(raw)
293+
except ValueError:
294+
return None
295+
296+
297+
def _hash_or_empty(session_id: str, request: Request) -> str:
298+
if not session_id:
299+
return ''
300+
return hash_session_id(session_id, request.island.key)

src/marketplace/apps.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.apps import AppConfig
2+
3+
4+
class MarketplaceConfig(AppConfig):
5+
default_auto_field = 'django.db.models.BigAutoField'
6+
name = 'marketplace'
7+
verbose_name = 'Marketplace'

src/marketplace/management/__init__.py

Whitespace-only changes.

src/marketplace/management/commands/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)