Skip to content

Commit d08a725

Browse files
Restrict reviews to purchased products and improve Docker configuration
1 parent 75aa262 commit d08a725

File tree

10 files changed

+156
-16
lines changed

10 files changed

+156
-16
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ __pycache__/
88
*.pyd
99
*.DS_Store
1010
*.sock
11-
.env
11+
.env

Dockerfile

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,37 @@
22
FROM python:3.12.3-slim
33

44
# Install system dependencies
5+
# This layer is cached unless the base image or the list of packages changes
56
RUN apt-get update && apt-get install -y \
67
build-essential \
78
libpq-dev \
9+
curl \
810
&& rm -rf /var/lib/apt/lists/*
911

1012
# Set environment variables
13+
# These layers are cached as they don't depend on external files
1114
ENV PYTHONDONTWRITEBYTECODE=1
1215
ENV PYTHONUNBUFFERED=1
1316

1417
# Set work directory
18+
# This layer is cached unless the work directory path changes
1519
WORKDIR /code
1620

21+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
22+
1723
# Install dependencies
24+
# Copying `requirements.txt` first ensures this layer is cached if dependencies don't change
1825
COPY requirements.txt .
19-
RUN pip install --upgrade pip && pip install -r requirements.txt
26+
RUN uv pip install --upgrade pip -r requirements.txt --system
2027

2128
# Copy the Django project
29+
# This layer is rebuilt only if the project files change
2230
COPY . .
31+
32+
# Expose the application port
33+
# This layer is cached unless the exposed port changes
34+
EXPOSE 8000
35+
36+
# Default command to run the application
37+
# This layer is cached unless the command changes
38+
CMD ["entrypoint.sh"]

chat/consumers.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@
1212
class ChatConsumer(AsyncWebsocketConsumer):
1313
"""WebSocket consumer for handling private product chats between sellers and buyers."""
1414

15+
def __init__(self, *args, **kwargs):
16+
super().__init__(*args, **kwargs)
17+
self.user = None
18+
self.product = None
19+
self.room_name = None
20+
self.room_group_name = None
21+
self.product_id = None
22+
1523
async def connect(self):
1624
"""
1725
Handle WebSocket connection.
@@ -33,14 +41,14 @@ async def connect(self):
3341
self.product_id = self.scope['url_route']['kwargs']['product_id']
3442

3543
try:
36-
self.product = await Product.objects.aget(id=self.product_id)
44+
self.product = await Product.objects.aget(product_id=self.product_id)
3745
except Product.DoesNotExist:
3846
await self.close()
3947
return
4048

4149
# Create a unique room name for the seller-buyer-product combination
4250
# Sort user IDs to ensure same room name regardless of who connects first
43-
user_ids = sorted([str(self.user.id), str(self.product.seller.id)])
51+
user_ids = sorted([str(self.user.id), str(self.product.user.id)])
4452
self.room_name = f'chat_product_{self.product_id}_users_{"_".join(user_ids)}'
4553
self.room_group_name = f'chat_{self.room_name}'
4654

chat/tests.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
from channels.testing import WebsocketCommunicator
12
from django.contrib.auth import get_user_model
3+
from django.test import TestCase
24
from django.urls import reverse
35
from rest_framework.test import APITestCase
46
from rest_framework_simplejwt.tokens import RefreshToken
57

8+
from chat.consumers import ChatConsumer
69
from chat.models import Message
7-
from shop.models import Product
10+
from shop.models import Product, Category
811

912
User = get_user_model()
1013

@@ -52,3 +55,48 @@ def test_api_get_messages(self):
5255
response = self.client.get(self.url)
5356
self.assertEqual(response.status_code, 200)
5457
self.assertIn('data', response.data)
58+
59+
60+
class ChatConsumerTest(TestCase):
61+
def setUp(self):
62+
# Create test users
63+
self.User = get_user_model()
64+
self.seller = self.User.objects.create_user(username='seller', password='password', email='[email protected]')
65+
self.buyer = self.User.objects.create_user(username='buyer', password='password', email='[email protected]')
66+
67+
# Create a test product
68+
self.category = Category.objects.create(name='TestCat', slug='testcat')
69+
self.product = Product.objects.create(name='Test Product', user=self.seller, price=10.00, stock=5, category=self.category)
70+
71+
async def test_chat_consumer(self):
72+
# Simulate WebSocket connection for the buyer
73+
communicator = WebsocketCommunicator(
74+
ChatConsumer.as_asgi(),
75+
f'/ws/chat/{self.product.product_id}/'
76+
)
77+
communicator.scope['user'] = self.buyer
78+
communicator.scope['url_route'] = {'kwargs': {'product_id': self.product.product_id}}
79+
80+
connected, _ = await communicator.connect()
81+
self.assertTrue(connected)
82+
83+
# Send a message from the buyer
84+
message = 'Hello, I am interested in your product!'
85+
await communicator.send_json_to({
86+
'message': message
87+
})
88+
89+
# Receive the message from the WebSocket
90+
response = await communicator.receive_json_from()
91+
self.assertEqual(response['message'], message)
92+
self.assertEqual(response['sender'], self.buyer.username)
93+
94+
# Check if the message was saved in the database
95+
saved_message = Message.objects.get(content=message)
96+
self.assertEqual(saved_message.sender, self.buyer)
97+
self.assertEqual(saved_message.recipient, self.seller)
98+
self.assertEqual(saved_message.product, self.product)
99+
100+
# Disconnect the WebSocket
101+
await communicator.disconnect()
102+

docker-compose.yml

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,45 @@
11
services:
2-
db:
2+
database:
33
image: postgres:17.4
4+
container_name: database
45
restart: always
56
volumes:
6-
- db_data:/var/lib/postgresql/data
7-
env_file:
8-
- .env
7+
- database:/var/lib/postgresql/data
8+
environment:
9+
POSTGRES_USER: ${POSTGRES_USER}
10+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
11+
POSTGRES_DB: ${POSTGRES_DB}
12+
healthcheck:
13+
test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}" ]
14+
interval: 10s
15+
timeout: 5s
16+
retries: 5
917

1018
cache:
1119
image: redis:7.4.2
20+
container_name: cache
1221
restart: always
1322
ports:
1423
- "6379:6379"
1524

1625
rabbitmq:
1726
image: rabbitmq:3-management
27+
container_name: broker
1828
restart: always
1929
ports:
2030
- "15672:15672"
2131
- "5672:5672"
2232
env_file:
2333
- .env
34+
healthcheck:
35+
test: [ "CMD", "rabbitmq-diagnostics", "ping" ]
36+
interval: 10s
37+
timeout: 5s
38+
retries: 3
2439

2540
web:
2641
build: .
42+
container_name: backend
2743
command: [ "./wait-for-it.sh", "db:5432", "--",
2844
"uwsgi", "--ini", "/code/config/uwsgi/uwsgi.ini" ]
2945
restart: always
@@ -34,12 +50,13 @@ services:
3450
env_file:
3551
- .env
3652
depends_on:
37-
- db
3853
- cache
3954
- rabbitmq
55+
- database
4056

4157
nginx:
4258
image: nginx:1.27.4
59+
container_name: nginx
4360
restart: always
4461
volumes:
4562
- ./config/nginx:/etc/nginx/templates
@@ -48,4 +65,4 @@ services:
4865
- "80:80"
4966

5067
volumes:
51-
db_data:
68+
database:

ecommerce_api/settings/base.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,11 @@
122122
DATABASES = {
123123
'default': {
124124
'ENGINE': 'django.db.backends.postgresql',
125-
'NAME': config('DB_NAME'),
126-
'USER': config('DB_USER'),
127-
'PASSWORD': config('DB_PASSWORD'),
125+
'NAME': config('POSTGRES_DB'),
126+
'USER': config('POSTGRES_USER'),
127+
'PASSWORD': config('POSTGRES_PASSWORD'),
128128
'HOST': config('DB_HOST'),
129-
'PORT': 5432,
129+
'PORT': config('DB_PORT'),
130130
}
131131
}
132132

entrypoint.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#!/bin/sh
2+
3+
echo "Running migrations..."
4+
python manage.py migrate
5+
6+
echo "Starting server..."
7+
python manage.py runserver 0.0.0.0:8000
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Generated by Django 5.2 on 2025-04-29 11:58
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('shop', '0003_rename_image_product_thumbnail'),
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
]
13+
14+
operations = [
15+
migrations.AlterUniqueTogether(
16+
name='review',
17+
unique_together={('product', 'user')},
18+
),
19+
migrations.AddIndex(
20+
model_name='review',
21+
index=models.Index(fields=['product'], name='shop_review_product_d2a5c4_idx'),
22+
),
23+
migrations.AddIndex(
24+
model_name='review',
25+
index=models.Index(fields=['user'], name='shop_review_user_id_fcb4ba_idx'),
26+
),
27+
]

shop/models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,23 @@ class Meta:
142142
verbose_name = "Review"
143143
verbose_name_plural = "Reviews"
144144
ordering = ["-created"]
145+
indexes = [
146+
models.Index(fields=['product']),
147+
models.Index(fields=['user']),
148+
]
149+
constraints = [
150+
models.UniqueConstraint(
151+
fields=['user', 'product'],
152+
name='unique_user_product_review',
153+
violation_error_message='A user can only leave one review per product.'
154+
)
155+
]
156+
157+
def save(self, *args, **kwargs):
158+
from django.core.exceptions import ValidationError
159+
if not self.product.order_items.filter(order__user=self.user).exists():
160+
raise ValidationError("You can only review products you have purchased.")
161+
super().save(*args, **kwargs)
145162

146163
def __str__(self):
147164
return f"Review by {self.user} for {self.product} - {self.rating} stars"

shop/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ class ReviewViewSet(viewsets.ModelViewSet):
101101
"""
102102
API endpoint for creating, updating, deleting, and listing reviews for a specific product.
103103
Only authenticated users can create reviews. Only the review owner or a staff member can delete reviews.
104-
Only the review owner can update reviews.
104+
Only the review owner can update reviews. Users can only leave one review per product, and only for products they have purchased.
105105
"""
106106
serializer_class = ReviewSerializer
107107
permission_classes = [IsAuthenticatedOrReadOnly]

0 commit comments

Comments
 (0)