diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..9c84f7eb --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: Тесты (shop/tests) + +on: + push: + branches: [ main, master ] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Установка Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Установка Django + run: | + python -m pip install --upgrade pip + pip install Django coverage + + - name: Запуск только тестов из shop/tests/ + run: | + python manage.py test shop.tests --verbosity=2 --keepdb + + - name: Покрытие кода + run: | + coverage run --source=shop manage.py test shop.tests + coverage report -m \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4f37e584..b6fbf53e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,102 @@ + __pycache__/ +*.py[cod] +*$py.class +*.pyo +*.pyd +.Python +env/ +venv/ +.venv +ENV/ +env.bak/ +venv.bak/ +pip-log.txt +pip-delete-me.txt + +.venv*/ +env*/ +venv*/ +ENV*/ +env*/ + +# Django stuff +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +media/ +staticfiles/ +*.sqlite3 + +.idea/ +.vscode/ +*.sublime-project +*.sublime-workspace +*.suo +*.sdf +*.opensdf +*.sln +*.user +*.userosscache +*.sln.docstates + +.vscode/ + .DS_Store +.DS_Store +.AppleDouble +.LSOverride + +._* + +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + +node_modules/ +npm-debug.log +yarn-debug.log* +yarn-error.log* + +htmlcov/ +.coverage +.coverage.* +cache/ +nosetests.xml +coverage.xml +*.cover +.pytest_cache/ + +celerybeat-schedule +celerybeat.pid + +docs/_build/ + +.ipynb_checkpoints + +.mypy_cache/ +.dmypy.json +dmypy.json + +.pyre/ + +*.env .env +.env.local +.env.*.local +secret_key.txt + +*.orig +*.rej +*.bak +*.backup \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..87b2197f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Константин Захаров + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/shop/fixtures/products.yaml b/shop/fixtures/products.yaml index dbe7c6ab..f608bf49 100644 --- a/shop/fixtures/products.yaml +++ b/shop/fixtures/products.yaml @@ -1,15 +1,41 @@ - model: shop.product pk: 1 fields: - name: Стол + name: Стол письменный price: 2000 + quantity: 8 + - model: shop.product pk: 2 fields: - name: Стул + name: Стул офисный price: 1000 + quantity: 25 + - model: shop.product pk: 3 fields: - name: Табурет + name: Табурет кухонный price: 500 + quantity: 0 + +- model: shop.product + pk: 4 + fields: + name: Шкаф двухстворчатый + price: 7500 + quantity: 3 + +- model: shop.product + pk: 5 + fields: + name: Кровать односпальная + price: 12000 + quantity: 5 + +- model: shop.product + pk: 6 + fields: + name: Диван угловой + price: 35000 + quantity: 1 \ No newline at end of file diff --git a/shop/models.py b/shop/models.py index 68f38c59..b200f19b 100644 --- a/shop/models.py +++ b/shop/models.py @@ -1,12 +1,36 @@ -from django.db import models +from django.core.exceptions import ValidationError +from django.db import models, transaction # Create your models here. class Product(models.Model): name = models.CharField(max_length=200) price = models.PositiveIntegerField() + quantity = models.PositiveIntegerField(default=0) + + def can_buy(self, count=1): + return self.quantity >= count + + def buy(self, count=1): + if not self.can_buy(count): + raise ValidationError(f"Недостаточно товара {self.name} на складе") + self.quantity -= count + self.save(update_fields=['quantity']) + + def clean(self): + if self.quantity < 0: + raise ValidationError("количество не может быть отрицательным") + class Purchase(models.Model): product = models.ForeignKey(Product, on_delete=models.CASCADE) person = models.CharField(max_length=200) address = models.CharField(max_length=200) - date = models.DateTimeField(auto_now_add=True) \ No newline at end of file + date = models.DateTimeField(auto_now_add=True) + + def save(self, *args, **kwargs): + if self.pk is None: # только при создании новой покупки + # Что бы откатиться если ошибки + with transaction.atomic(): + product = Product.objects.select_for_update().get(pk=self.product_id) + product.buy() # уменьшаем на 1 штуку + super().save(*args, **kwargs) diff --git a/shop/templates/shop/index.html b/shop/templates/shop/index.html index 643f03e5..4e96fa94 100644 --- a/shop/templates/shop/index.html +++ b/shop/templates/shop/index.html @@ -1,26 +1,32 @@ - + Товары -
-

Список

- - - - - - - {% for p in products %} - - - - - - {% endfor %} -

Наименование

Цена

{{ p.name }}

{{ p.price }}

Купить

-
+

Товары в магазине

+ + + + + + + + {% for p in products %} + + + + + + + {% endfor %} +
НаименованиеЦенаОстатокДействие
{{ p.name }}{{ p.price }} ₽{{ p.quantity }} шт. + {% if p.quantity > 0 %} + Купить + {% else %} + Нет в наличии + {% endif %} +
- + \ No newline at end of file diff --git a/shop/templates/shop/purchase_form.html b/shop/templates/shop/purchase_form.html index 3baf70f5..8e24ae54 100644 --- a/shop/templates/shop/purchase_form.html +++ b/shop/templates/shop/purchase_form.html @@ -1,28 +1,16 @@ - - Покупка + + Оформление покупки {{ product.name }} -
-

Покупка

-
{% csrf_token %} - - - - - - - - - - - -

Введите свое имя

Введите адрес доставки:

- -
-
-
+

Покупка товара: {{ product.name }} (цена {{ product.price }} )

+
+ {% csrf_token %} + {{ form.as_p }} + +
+ ← Назад к списку - + \ No newline at end of file diff --git a/shop/tests/test_models.py b/shop/tests/test_models.py index 65a4bffc..c364cc30 100644 --- a/shop/tests/test_models.py +++ b/shop/tests/test_models.py @@ -1,38 +1,99 @@ +# tests/test_models.py from django.test import TestCase -from shop.models import Product, Purchase +from django.core.exceptions import ValidationError from datetime import datetime +from shop.models import Product, Purchase + + class ProductTestCase(TestCase): def setUp(self): - Product.objects.create(name="book", price="740") - Product.objects.create(name="pencil", price="50") + Product.objects.create(name="book", price=740, quantity=100) + Product.objects.create(name="pencil", price=50, quantity=50) - def test_correctness_types(self): + def test_correctness_types(self): self.assertIsInstance(Product.objects.get(name="book").name, str) self.assertIsInstance(Product.objects.get(name="book").price, int) self.assertIsInstance(Product.objects.get(name="pencil").name, str) - self.assertIsInstance(Product.objects.get(name="pencil").price, int) + self.assertIsInstance(Product.objects.get(name="pencil").price, int) def test_correctness_data(self): - self.assertTrue(Product.objects.get(name="book").price == 740) - self.assertTrue(Product.objects.get(name="pencil").price == 50) + self.assertEqual(Product.objects.get(name="book").price, 740) + self.assertEqual(Product.objects.get(name="pencil").price, 50) class PurchaseTestCase(TestCase): def setUp(self): - self.product_book = Product.objects.create(name="book", price="740") + self.product_book = Product.objects.create(name="book", price=740, quantity=10) self.datetime = datetime.now() - Purchase.objects.create(product=self.product_book, - person="Ivanov", - address="Svetlaya St.") + + # колво = 10 > 0 + Purchase.objects.create( + product=self.product_book, + person="Ivanov", + address="Svetlaya St." + ) def test_correctness_types(self): - self.assertIsInstance(Purchase.objects.get(product=self.product_book).person, str) - self.assertIsInstance(Purchase.objects.get(product=self.product_book).address, str) - self.assertIsInstance(Purchase.objects.get(product=self.product_book).date, datetime) + purchase = Purchase.objects.get(product=self.product_book) + self.assertIsInstance(purchase.person, str) + self.assertIsInstance(purchase.address, str) + self.assertIsInstance(purchase.date, datetime) def test_correctness_data(self): - self.assertTrue(Purchase.objects.get(product=self.product_book).person == "Ivanov") - self.assertTrue(Purchase.objects.get(product=self.product_book).address == "Svetlaya St.") - self.assertTrue(Purchase.objects.get(product=self.product_book).date.replace(microsecond=0) == \ - self.datetime.replace(microsecond=0)) \ No newline at end of file + purchase = Purchase.objects.get(product=self.product_book) + self.assertEqual(purchase.person, "Ivanov") + self.assertEqual(purchase.address, "Svetlaya St.") + self.assertAlmostEqual( + purchase.date.replace(microsecond=0), + self.datetime.replace(microsecond=0), + delta=2 # ±2 секунды для погрешности + ) + + +class ProductStockTestCase(TestCase): + def setUp(self): + self.book = Product.objects.create(name="Python Book", price=1500, quantity=5) + self.sold_out = Product.objects.create(name="Old Phone", price=10000, quantity=0) + + def test_quantity_default_and_type(self): + new_product = Product.objects.create(name="Test", price=999) + self.assertEqual(new_product.quantity, 0) # default=0 + self.assertIsInstance(new_product.quantity, int) + + def test_can_buy_and_buy_method(self): + self.assertTrue(self.book.can_buy(3)) + self.assertFalse(self.book.can_buy(10)) + self.assertFalse(self.sold_out.can_buy()) + + self.book.buy(2) + self.book.refresh_from_db() + self.assertEqual(self.book.quantity, 3) + + with self.assertRaises(ValidationError): + self.book.buy(10) + + with self.assertRaises(ValidationError): + self.sold_out.buy() + + +class PurchaseStockTestCase(TestCase): + def setUp(self): + self.product = Product.objects.create(name="Laptop", price=80000, quantity=2) + + def test_purchase_decreases_stock(self): + initial = self.product.quantity + Purchase.objects.create(product=self.product, person="Alex", address="Moscow") + self.product.refresh_from_db() + self.assertEqual(self.product.quantity, initial - 1) + + def test_cannot_buy_when_no_stock(self): + # всё купим + for _ in range(2): + Purchase.objects.create(product=self.product, person="Bot", address="—") + + self.product.refresh_from_db() + self.assertEqual(self.product.quantity, 0) + + with self.assertRaises(ValidationError): + Purchase.objects.create(product=self.product, person="Late", address="Far") \ No newline at end of file diff --git a/shop/tests/test_views.py b/shop/tests/test_views.py index 8fa0d879..74211607 100644 --- a/shop/tests/test_views.py +++ b/shop/tests/test_views.py @@ -1,5 +1,9 @@ +# tests/test_views.py from django.test import TestCase, Client -from shop.views import PurchaseCreate +from django.urls import reverse + +from shop.models import Product, Purchase + class PurchaseCreateTestCase(TestCase): def setUp(self): @@ -7,4 +11,81 @@ def setUp(self): def test_webpage_accessibility(self): response = self.client.get('/') - self.assertEqual(response.status_code, 200) \ No newline at end of file + self.assertEqual(response.status_code, 200) + + +class IndexViewTest(TestCase): + def setUp(self): + self.client = Client() + Product.objects.create(name="Телефон", price=30000, quantity=7) + Product.objects.create(name="Наушники", price=5000, quantity=0) + + def test_index_status_code(self): + response = self.client.get(reverse('index')) + self.assertEqual(response.status_code, 200) + + def test_index_shows_quantity_and_disables_buy_when_zero(self): + response = self.client.get(reverse('index')) + self.assertContains(response, "7 шт.") + self.assertContains(response, "Нет в наличии") + + content = response.content.decode() + buy_links_count = content.count('href="/buy/') + self.assertEqual(buy_links_count, 1) + + +class PurchaseCreateViewTest(TestCase): + def setUp(self): + self.client = Client() + self.p1 = Product.objects.create(name="Планшет", price=25000, quantity=2) + self.p2 = Product.objects.create(name="Чехол", price=1500, quantity=0) + + def test_buy_page_loads_when_in_stock(self): + response = self.client.get(reverse('buy', args=[self.p1.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Планшет") + + def test_form_shows_product_name_and_price(self): + response = self.client.get(reverse('buy', args=[self.p1.id])) + self.assertContains(response, "Планшет") + self.assertContains(response, "25000") + + def test_successful_purchase(self): + response = self.client.post(reverse('buy', args=[self.p1.id]), { + 'person': 'Сергей', + 'address': 'Москва, ул. Тверская' + }, follow=True) # после редиректа получить финальную страницу + + # После редиректа статус 200, а не 302 + self.assertEqual(response.status_code, 200) + self.assertRedirects(response, reverse('index')) + + self.p1.refresh_from_db() + self.assertEqual(self.p1.quantity, 1) + + purchase = Purchase.objects.latest('date') + self.assertEqual(purchase.person, 'Сергей') + self.assertEqual(purchase.product, self.p1) + + def test_cannot_buy_when_quantity_zero_before_request(self): + response = self.client.post(reverse('buy', args=[self.p2.id]), { + 'person': 'Кто-то', + 'address': 'Где-то' + }) + self.assertEqual(response.status_code, 400) + self.assertIn("нет в наличии", response.content.decode().lower()) + + def test_cannot_buy_when_race_condition(self): + # имитируем одновременные покупки + for name in ["Первый", "Второй"]: + Purchase.objects.create(product=self.p1, person=name, address="—") + + self.p1.refresh_from_db() + self.assertEqual(self.p1.quantity, 0) + + response = self.client.post(reverse('buy', args=[self.p1.id]), { + 'person': 'Опоздал', + 'address': 'Далеко' + }) + self.assertEqual(response.status_code, 400) + self.assertIn("товара нет в наличии", response.content.decode().lower()) \ No newline at end of file diff --git a/shop/views.py b/shop/views.py index feb7eb60..277ccc3a 100644 --- a/shop/views.py +++ b/shop/views.py @@ -1,5 +1,7 @@ -from django.shortcuts import render -from django.http import HttpResponse +from django.core.exceptions import ValidationError +from django.shortcuts import render, get_object_or_404 +from django.http import HttpResponse, HttpResponseBadRequest +from django.urls import reverse_lazy from django.views.generic.edit import CreateView from .models import Product, Purchase @@ -13,9 +15,26 @@ def index(request): class PurchaseCreate(CreateView): model = Purchase - fields = ['product', 'person', 'address'] + fields = ['person', 'address'] + template_name = 'shop/purchase_form.html' + + success_url = reverse_lazy('index') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['product'] = get_object_or_404(Product, pk=self.kwargs['product_id']) + return context def form_valid(self, form): - self.object = form.save() - return HttpResponse(f'Спасибо за покупку, {self.object.person}!') + product = get_object_or_404(Product, pk=self.kwargs['product_id']) + + if product.quantity <= 0: + return HttpResponseBadRequest("Товара нет в наличии") + + form.instance.product = product + + try: + return super().form_valid(form) + except ValidationError: + return HttpResponseBadRequest("Товара недостаточно на складе (возможно, только что раскупили)")