feat: add favorites feature for users
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:
2026-05-10 20:06:12 +02:00
parent 812065a34b
commit 29d8302a4f
30 changed files with 1042 additions and 65 deletions

1
favorites/__init__.py Normal file
View File

@@ -0,0 +1 @@

11
favorites/admin.py Normal file
View 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
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class FavoritesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "favorites"

View 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')],
},
),
]

View File

@@ -0,0 +1 @@

32
favorites/models.py Normal file
View 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
View 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
View 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
View 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
View 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)