feat: add favorites feature for users
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s
- Implemented `Favorite` model with unique user-item constraint and timestamped records - Added `favorites:index` and `favorites:toggle` views with AJAX support for toggling favorites - Built templates and front-end assets for the favorites page and integration with item grids - Extended header with favorites link and dynamic counter - Added tests covering models, views, and templates for the favorites functionality - Updated admin to enable management of favorites via Django admin interface
This commit is contained in:
1
favorites/__init__.py
Normal file
1
favorites/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
11
favorites/admin.py
Normal file
11
favorites/admin.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Favorite
|
||||
|
||||
|
||||
@admin.register(Favorite)
|
||||
class FavoriteAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "item", "created_at")
|
||||
list_filter = ("created_at",)
|
||||
search_fields = ("user__username", "user__email", "item__name", "item__slug")
|
||||
|
||||
7
favorites/apps.py
Normal file
7
favorites/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class FavoritesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "favorites"
|
||||
|
||||
33
favorites/migrations/0001_initial.py
Normal file
33
favorites/migrations/0001_initial.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 6.0.4 on 2026-05-10 17:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('items', '0003_subcategory_item_subcategory_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Favorite',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')),
|
||||
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_entries', to='items.item', verbose_name='Товар')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_entries', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Избранный товар',
|
||||
'verbose_name_plural': 'Избранные товары',
|
||||
'ordering': ('-created_at', '-id'),
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'item'), name='unique_favorite_user_item')],
|
||||
},
|
||||
),
|
||||
]
|
||||
1
favorites/migrations/__init__.py
Normal file
1
favorites/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
32
favorites/models.py
Normal file
32
favorites/models.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from items.models import Item
|
||||
|
||||
|
||||
class Favorite(models.Model):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="favorite_entries",
|
||||
verbose_name="Пользователь",
|
||||
)
|
||||
item = models.ForeignKey(
|
||||
Item,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="favorite_entries",
|
||||
verbose_name="Товар",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата добавления")
|
||||
|
||||
class Meta:
|
||||
ordering = ("-created_at", "-id")
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=("user", "item"), name="unique_favorite_user_item"),
|
||||
]
|
||||
verbose_name = "Избранный товар"
|
||||
verbose_name_plural = "Избранные товары"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} -> {self.item}"
|
||||
|
||||
12
favorites/services.py
Normal file
12
favorites/services.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from typing import Any
|
||||
|
||||
from favorites.models import Favorite
|
||||
|
||||
|
||||
def get_user_favorite_item_ids(user: Any) -> set[int]:
|
||||
if not user.is_authenticated:
|
||||
return set()
|
||||
|
||||
return set(
|
||||
Favorite.objects.filter(user=user).values_list("item_id", flat=True)
|
||||
)
|
||||
120
favorites/tests.py
Normal file
120
favorites/tests.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import IntegrityError
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from favorites.models import Favorite
|
||||
from items.models import Category, Item, ItemImage, Subcategory
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class FavoritePageTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="marina",
|
||||
email="marina@example.com",
|
||||
password="StrongPassword123",
|
||||
is_active=True,
|
||||
)
|
||||
self.plumbing = Category.objects.create(name="Сантехника", slug="plumbing")
|
||||
self.sinks = Subcategory.objects.create(name="Мойки", slug="sinks", category=self.plumbing)
|
||||
self.taps = Subcategory.objects.create(name="Смесители", slug="taps", category=self.plumbing)
|
||||
self.sink = Item.objects.create(
|
||||
name="Кухонная мойка",
|
||||
slug="kitchen-sink",
|
||||
category=self.plumbing,
|
||||
subcategory=self.sinks,
|
||||
description="Искусственный камень",
|
||||
price="1600.00",
|
||||
is_available=True,
|
||||
)
|
||||
self.tap = Item.objects.create(
|
||||
name="Смеситель Mono",
|
||||
slug="mono-tap",
|
||||
category=self.plumbing,
|
||||
subcategory=self.taps,
|
||||
description="Однорычажный смеситель",
|
||||
price="320.00",
|
||||
is_available=False,
|
||||
)
|
||||
|
||||
def test_favorites_page_redirects_unauthenticated_users(self):
|
||||
response = self.client.get(reverse("favorites:index"))
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn(reverse("users:login"), response["Location"])
|
||||
|
||||
def test_favorites_page_shows_empty_state(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(reverse("favorites:index"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "В избранном пока нет товаров.")
|
||||
|
||||
def test_favorites_page_shows_items_in_tile_grid(self):
|
||||
self.client.force_login(self.user)
|
||||
ItemImage.objects.create(
|
||||
item=self.sink,
|
||||
image="items/sink-primary.jpg",
|
||||
alt="Главное фото мойки",
|
||||
sort_order=0,
|
||||
is_primary=True,
|
||||
)
|
||||
Favorite.objects.create(user=self.user, item=self.tap)
|
||||
Favorite.objects.create(user=self.user, item=self.sink)
|
||||
|
||||
response = self.client.get(reverse("favorites:index"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Избранное")
|
||||
self.assertContains(response, "Кухонная мойка")
|
||||
self.assertContains(response, "Смеситель Mono")
|
||||
self.assertContains(response, "/media/items/sink-primary.jpg")
|
||||
self.assertContains(response, 'href="/catalog/plumbing/kitchen-sink/"', html=False)
|
||||
self.assertContains(response, 'href="/catalog/plumbing/mono-tap/"', html=False)
|
||||
self.assertContains(response, "Удалить из избранного")
|
||||
|
||||
def test_toggle_endpoint_adds_and_removes_favorite(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
add_response = self.client.post(
|
||||
reverse("favorites:toggle"),
|
||||
data={"item_id": self.sink.pk, "next": reverse("favorites:index")},
|
||||
)
|
||||
|
||||
self.assertRedirects(add_response, reverse("favorites:index"))
|
||||
self.assertTrue(Favorite.objects.filter(user=self.user, item=self.sink).exists())
|
||||
|
||||
remove_response = self.client.post(
|
||||
reverse("favorites:toggle"),
|
||||
data={"item_id": self.sink.pk, "next": reverse("favorites:index")},
|
||||
)
|
||||
|
||||
self.assertRedirects(remove_response, reverse("favorites:index"))
|
||||
self.assertFalse(Favorite.objects.filter(user=self.user, item=self.sink).exists())
|
||||
|
||||
def test_toggle_endpoint_returns_json_for_ajax_requests(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("favorites:toggle"),
|
||||
data={"item_id": self.sink.pk, "next": reverse("favorites:index")},
|
||||
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["Content-Type"], "application/json")
|
||||
self.assertEqual(response.json()["item_id"], self.sink.pk)
|
||||
self.assertTrue(response.json()["is_favorite"])
|
||||
self.assertEqual(response.json()["favorite_count"], 1)
|
||||
self.assertTrue(Favorite.objects.filter(user=self.user, item=self.sink).exists())
|
||||
|
||||
def test_favorite_pair_is_unique_per_user_and_item(self):
|
||||
Favorite.objects.create(user=self.user, item=self.sink)
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
Favorite.objects.create(user=self.user, item=self.sink)
|
||||
10
favorites/urls.py
Normal file
10
favorites/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from favorites import views
|
||||
|
||||
app_name = "favorites"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.index, name="index"),
|
||||
path("toggle/", views.toggle, name="toggle"),
|
||||
]
|
||||
73
favorites/views.py
Normal file
73
favorites/views.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from favorites.models import Favorite
|
||||
from items.models import Item
|
||||
|
||||
|
||||
def wants_json_response(request: HttpRequest) -> bool:
|
||||
return (
|
||||
request.headers.get("x-requested-with") == "XMLHttpRequest"
|
||||
or "application/json" in request.headers.get("accept", "")
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def index(request: HttpRequest) -> HttpResponse:
|
||||
favorite_records = (
|
||||
Favorite.objects.filter(user=request.user)
|
||||
.select_related("item__category", "item__subcategory")
|
||||
.prefetch_related("item__images")
|
||||
.order_by("-created_at", "-id")
|
||||
)
|
||||
items = [favorite.item for favorite in favorite_records]
|
||||
|
||||
return render(request, "favorites/index.html", {
|
||||
"items": items,
|
||||
"favorite_count": len(items),
|
||||
"favorite_item_ids": {item.id for item in items},
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def toggle(request: HttpRequest) -> HttpResponse:
|
||||
item_id = request.POST.get("item_id")
|
||||
item = get_object_or_404(Item, pk=item_id)
|
||||
|
||||
favorite = Favorite.objects.filter(user=request.user, item=item).first()
|
||||
if favorite:
|
||||
favorite.delete()
|
||||
is_favorite = False
|
||||
message = f"Товар «{item.name}» удалён из избранного."
|
||||
else:
|
||||
Favorite.objects.create(user=request.user, item=item)
|
||||
is_favorite = True
|
||||
message = f"Товар «{item.name}» добавлен в избранное."
|
||||
|
||||
favorite_count = Favorite.objects.filter(user=request.user).count()
|
||||
next_url = request.POST.get("next") or reverse("favorites:index")
|
||||
|
||||
if wants_json_response(request):
|
||||
return JsonResponse({
|
||||
"item_id": item.pk,
|
||||
"is_favorite": is_favorite,
|
||||
"favorite_count": favorite_count,
|
||||
"message": message,
|
||||
})
|
||||
|
||||
messages.success(request, message)
|
||||
|
||||
if not url_has_allowed_host_and_scheme(
|
||||
next_url,
|
||||
allowed_hosts={request.get_host()},
|
||||
require_https=request.is_secure(),
|
||||
):
|
||||
next_url = reverse("favorites:index")
|
||||
|
||||
return redirect(next_url)
|
||||
Reference in New Issue
Block a user