Skip to content

Commit

Permalink
Update the README.md & add to the tests.
Browse files Browse the repository at this point in the history
  • Loading branch information
kirkoov committed May 2, 2024
1 parent 6b326c3 commit a384a87
Show file tree
Hide file tree
Showing 4 changed files with 162 additions and 105 deletions.
44 changes: 32 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Foodgram
Both a recipe website & a shopping list service for you to never forget what you need to buy to cook that fancy meal you've heard or seen. In English or Russian for starters.
Both a recipe website & a shopping list service for you to never forget what
you need to buy to cook that fancy meal you've heard or seen, featuring its
interface in English or
Russian thanks to `rosetta`. This is my main project to
showcase my skills
as a
Python backend developer able to docker & pytest a Django/DRF project. At
present, I'm
still on
it with more tests to go,
since my goal is 'to cover it all'. Please
find in the
description below the tools
and stack used. Made with ❤️ on the SublimeText4 and continued on the
PyCharm 2024.
1.1
(Community
Edition), no affiliation to either implied.

## Table of contents
- [Description](#description)
Expand Down Expand Up @@ -27,7 +44,7 @@ This project helped me a lot in further grasping the following:
- DevOps, including CI/CD;
- Using both [DjDT](https://django-debug-toolbar.readthedocs.io/en/latest/) for the dev & Telegram bot notifications about GitHub Actions deploys - for better performance & automated deploys.

Tools & stack: #Python #Django #DRF #Json #Yaml #API #Docker #Nginx #PostgreSQL #Gunicorn #Djoser #JWT #Postman #TelegramBot #SublimeText #Flake8 #Ruff #Black #Mypy #DjDT #Django-cleanup
Tools & stack: #Python #Django #DRF #Json #Yaml #API #Docker #Nginx #PostgreSQL #Gunicorn #Djoser #JWT #Postman #TelegramBot #Flake8 #Ruff #Black #Mypy #DjDT #Django-cleanup

[Back to TOC](#table-of-contents)

Expand Down Expand Up @@ -75,7 +92,9 @@ Then run:
- (optional) `python manage.py test` or `poetry run pytest` from the backend folder containing the `pytest.ini`;
- ```python manage.py runserver```.

<b>NB</b>: to handle img consistency, <b>[django-cleanup](https://pypi.org/project/django-cleanup/)</b> is used. By default, the admin zone accepts images<=1Mb, although when running live locally, the frontend may accept larger imgs. Still, in a live server case, the nginx container will instruct its Docker cousins not to.
<b>NB</b>: to handle img consistency, <b>[django-cleanup](https://pypi.
org/project/django-cleanup/)</b> is used. By default, the admin zone accepts
images<=1Mb, although when running live locally, the frontend may accept larger images. Still, in a live server case, the nginx container will instruct its Docker cousins not to.

##### 4. Skip if para. 2 above doesn't apply. Back in the browser, reload the page http://localhost:3000 for the test recipes to appear.

Expand Down Expand Up @@ -111,7 +130,7 @@ image: nginx:1.19.3
```

##### 3. Define the admin zone language (the default Eng vs Rus):
- change the settings.py's LANGUAGE_CODE accordingly;
- change the LANGUAGE_CODE accordingly (settings.py);
- unzip/replace the frontend public & src folders (see the zip files);
- if you want the ingredients in Eng too, see more details below, just bear in mind that all of them can be changed in the backend root folder's csv-files (with their bak cousins saved in the data folder).

Expand Down Expand Up @@ -148,9 +167,9 @@ which may eventually include (->):
- or/and you may also want to use the default ingredients: ```sudo docker compose exec backend python manage.py import_csv eng``` + ```sudo docker compose exec backend python manage.py import_csv rus```;
- then check in the admin zone if these imported ingredients (translated) are in the DB.

<b>NB</b>: if for some reason this is not the first time you run these commands & the Docker volumes have not been rm'ed, all such data will remain as is & you may see messages about duplicate values in the DB or/and that no migrations are necessary.
<b>NB</b>: if for some reason this is not the first time you run these commands & the Docker volumes have not been `rm`ed, all such data will remain as is & you may see messages about duplicate values in the DB or/and that no migrations are necessary.

<b>NB</b>: if you plan to work with both Rus/Eng translations, make sure the settings.py's lang_code has the value you need, and the make/compilemessages do work in the backend container. Open another Terminal and from the same infra folder run:
<b>NB</b>: if you plan to work with both Rus/Eng translations, make sure the settings.py lang_code has the value you need, and the `makemessages`/`compilemessages` do work in the backend container. Open another Terminal and from the same infra folder run:
```sudo docker compose exec backend bash```;
```apt update && apt upgrade -y && apt install gettext-base && apt install gettext```. Then quit (```Ctrl+d```) and run:

Expand Down Expand Up @@ -191,9 +210,9 @@ Ubuntu 22, Docker 25.0.4 & docker compose v2.24.7
##### 4. Follow the 3-8 steps of the [Local Docker](#local-docker) install.

##### 5. If you never changed the ports & docker-compose file, the project recipes, admin page & docs should be live at:
- http(s)://yourDomainOrIPaddress/;
- http(s)://yourDomainOrIPaddress/admin/;
- http(s)://yourDomainOrIPaddress/api/docs/.
- http(s)://your_domain_or_IP_address/;
- http(s)://your_domain_or_IP_address/admin/;
- http(s)://your_domain_or_IP_address/api/docs/.

[Back to TOC](#table-of-contents)

Expand Down Expand Up @@ -249,9 +268,10 @@ save, close & run
##### 6. If ok, take the same steps as from the prev instructions' para 2. Otherwise, stop & run without the -d flag to see the output.

##### 7. The project, admin page & docs availability:
- http(s)://yourDomainOrIPaddress/ (if you never changed the ports & docker-compose file);
- http(s)://yourDomainOrIPaddress/admin/;
- http(s)://yourDomainOrIPaddress/api/docs/.
- http(s)://your_domain_or_IP_address/ (if you never changed the ports &
docker-compose file);
- http(s)://your_domain_or_IP_address/admin/;
- http(s)://your_domain_or_IP_address/api/docs/.

[Back to TOC](#table-of-contents)

Expand Down
6 changes: 2 additions & 4 deletions backend/backend/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@

NUM_CHARS_INGREDIENT_NAME = NUM_CHARS_RECIPE_NAME = 200
NUM_CHARS_MEALTIME_HEX = 7
NUM_CHARS_MEALTIME_NAME = NUM_CHARS_MEALTIME_SLUG = (
NUM_CHARS_MEASUREMENT_UNIT
) = 200
NUM_CHARS_MEALTIME_NAME = NUM_CHARS_MEALTIME_SLUG = NUM_CHARS_MEASUREMENT_UNIT = 200

HEX_FIELD_REQ = "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"
SLUG_FIELD_REQ = "^[-a-zA-Z0-9_]+$"
Expand All @@ -28,5 +26,5 @@
TEST_LIMIT_LIST_USERS = 1

TEST_NUM_TAGS = 5
TEST_NUM_INGREDIENTS = 1000
TEST_NUM_INGREDIENTS = 2000
TEST_NUM_RECIPES = 100
177 changes: 116 additions & 61 deletions backend/recipes/tests.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import json

# import random
# from pprint import pprint as pp
import random
from typing import List

# import pytest
import pytest
from django.contrib.auth import get_user_model

# from django.db.utils import DataError, IntegrityError
from django.db import IntegrityError
from rest_framework import status
from rest_framework.test import APIClient, APIRequestFactory, APITestCase
from rest_framework.test import APIClient, APITestCase
from rest_framework.authtoken.models import Token

# from api.views import IngredientViewSet, RecipeViewSet, TagViewSet
from backend.constants import (
NUM_CHARS_INGREDIENT_NAME,
NUM_CHARS_MEALTIME_HEX,
Expand All @@ -20,8 +17,12 @@
NUM_CHARS_MEASUREMENT_UNIT,
TEST_NUM_INGREDIENTS,
TEST_NUM_TAGS,
TEST_NUM_RECIPES,
MIN_COOKING_TIME_MINS,
MAX_COOKING_TIME_MINS,
)
from .models import Ingredient, Tag
from conftest import get_standard_user_data
from .models import Ingredient, Recipe, Tag
from .validators import validate_hex_color, validate_slug_field

User = get_user_model()
Expand All @@ -33,12 +34,14 @@ class RecipeTests(APITestCase):
ingredients_url = f"{prefix}ingredients/"
recipes_url = f"{prefix}recipes/"
api_client = APIClient()
factory = APIRequestFactory()
test_tags: List[Tag] = []
test_ingredients: List[Ingredient] = []
test_recipes: List[Recipe] = []

@classmethod
def setUpTestData(cls):
cls.create_test_user()

for index in range(1, TEST_NUM_TAGS + 1):
tag = Tag(
name=f"Tag{index}",
Expand All @@ -48,19 +51,65 @@ def setUpTestData(cls):
cls.test_tags.append(tag)
Tag.objects.bulk_create(cls.test_tags)

for idx in range(1, TEST_NUM_INGREDIENTS):
for idx in range(1, TEST_NUM_INGREDIENTS + 1):
ingredient = Ingredient(
name=f"Ingredient{idx}",
measurement_unit="g",
)
cls.test_ingredients.append(ingredient)
Ingredient.objects.bulk_create(cls.test_ingredients)

# cls.test_recipes = []
# cls.request_recipes = cls.factory.get(cls.recipes_url)
# cls.view_recipe_detail = RecipeViewSet.as_view({"get": "retrieve"})
# cls.test_recipe_name = "Test recipe"
# cls.request_recipe_detail = cls.factory.get(f"{cls.recipes_url}/")
# for idx in range(1, TEST_NUM_RECIPES + 1):
# recipe = Recipe(
# ingredients=None, # [{"id": 1123, "amount": 10}],
# tags=[1, random.randint(2, TEST_NUM_TAGS)],
# image="data:image/png;(base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAgMAAABieywaAAAACVBMVEUAAAD///9)fX1/S0ecCAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAACklEQVQImWNoAAAAggCByxOyYQAAAABJRU5ErkJggg==",
# name=f"TestRecipe{idx}",
# text=f"Cooking instructions for test recipe{idx} go here",
# cooking_time=random.randint(
# MIN_COOKING_TIME_MINS, MAX_COOKING_TIME_MINS
# ),
# )
# name=models.CharField(
# max_length=NUM_CHARS_RECIPE_NAME,
# verbose_name=_("recipe name"),
# help_text=_("Enter a name for your recipe"),
# )
# image = models.ImageField(
# validators=[validate_img_size],
# upload_to="recipes/",
# verbose_name=_("recipe image"),
# help_text=_("Upload an image<=1MB for your recipe"),
# )
# text = models.TextField(
# verbose_name=_("recipe description"),
# help_text=_("Describe how to cook"),
# )
# cooking_time = models.PositiveSmallIntegerField(
# validators=[
# MinValueValidator(MIN_COOKING_TIME_MINS),
# MaxValueValidator(MAX_COOKING_TIME_MINS),
# ],
# verbose_name=_("cooking time"),
# help_text=_("Enter now many minutes it needs to cook"),
# )
# tags = models.ManyToManyField(
# Tag,
# verbose_name=_("mealtimes"),
# )
# author = models.ForeignKey(
# User,
# on_delete=models.CASCADE,
# related_name="recipes",
# verbose_name=_("author"),
# )
# ingredients = models.ManyToManyField(
# Ingredient,
# through=RecipeIngredient,
# verbose_name=_("ingredients"),
# )
# cls.test_recipes.append(recipe)
# Recipe.objects.bulk_create(cls.test_recipes)

def test_list_tags(self):
response = self.client.get(self.tags_url)
Expand Down Expand Up @@ -123,9 +172,7 @@ def test_list_ingredients(self):
)
for x in Ingredient.objects.all():
self.assertTrue(len(x.name) <= NUM_CHARS_INGREDIENT_NAME)
self.assertTrue(
len(x.measurement_unit) <= NUM_CHARS_MEASUREMENT_UNIT
)
self.assertTrue(len(x.measurement_unit) <= NUM_CHARS_MEASUREMENT_UNIT)
tmp_ingredients.append(x.name)
self.assertEqual(Ingredient.objects.count(), len(set(tmp_ingredients)))

Expand All @@ -136,27 +183,23 @@ def test_ingredient_search(self):
measurement_unit="shovel",
)
self.assertEqual(Ingredient.objects.count(), count_ini + 1)
response = self.client.get(
f"{self.ingredients_url}?name=find_me%20ingredient"
)
response = self.client.get(f"{self.ingredients_url}?name=find_me%20ingredient")
self.assertEqual(response.status_code, status.HTTP_200_OK)
if TEST_NUM_INGREDIENTS == 1000:
if TEST_NUM_INGREDIENTS == 2000:
self.assertEqual(
json.loads(response.content),
[
{
"id": 1000,
"id": 2001,
"name": "find_me ingredient",
"measurement_unit": "shovel",
}
],
)
response = self.client.get(f"{self.ingredients_url}?name=Ingredient")
self.assertEqual(response.status_code, status.HTTP_200_OK)
if TEST_NUM_INGREDIENTS == 1000:
self.assertEqual(
len(json.loads(response.content)), TEST_NUM_INGREDIENTS - 1
)
if TEST_NUM_INGREDIENTS == 2000:
self.assertEqual(len(json.loads(response.content)), TEST_NUM_INGREDIENTS)

def test_ingredient_detail(self):
id_ = len(self.test_ingredients)
Expand All @@ -173,39 +216,51 @@ def test_ingredient_detail(self):
},
)

# @staticmethod
# def test_create_same_ingredients_fails():
# with pytest.raises(IntegrityError):
# Ingredient.objects.create(
# name="The-same-ingredient",
# measurement_unit="kg",
# )
# Ingredient.objects.create(
# name="The-same-ingredient",
# measurement_unit="kg",
# )
#
# def test_get_ingredient_detail(self):
# id_ = 1
# request_detail = self.factory.get(
# f"http://testserver/api/ingredients/{id_}/"
# )
# response = self.view_ingredient_detail(request_detail, pk=id_)
# if response.render():
# self.assertEqual(
# json.loads(response.content),
# {
# "id": id_,
# "name": f"Ingredient{id_ - 1}",
# "measurement_unit": "g",
# },
# )
# else:
# raise DataError(
# "Recipes: no rendered content from the "
# "test_get_ingredient_detail()."
# )
#
@staticmethod
def test_create_same_ingredients():
with pytest.raises(IntegrityError):
Ingredient.objects.create(
name="oh_same-ingredient",
measurement_unit="kg",
)
Ingredient.objects.create(
name="oh_same-ingredient",
measurement_unit="kg",
)

# def test_recipe_page_available_anonymous_user(self):
# response = self.client.get(self.recipes_url)
# self.assertEqual(response.status_code, status.HTTP_200_OK)
@classmethod
def create_test_user(cls):
# "url": ,
# "token_url": ,
# "del_token_url": "/api/auth/token/logout/",
# "set_pwd_url": "/api/users/set_password/",
# "data": ,
user_data = {
"email": "standard@user.com",
"username": "test_standard_uza",
"first_name": "Test",
"last_name": "Standard",
"password": "wHat~Eva^_",
}
response = cls.api_client.post(
"/api/users/",
user_data,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert User.objects.count() == 1
login_data = {
"password": user_data["password"],
"email": user_data["email"],
}
response = cls.api_client.post(
"/api/auth/token/login/", login_data, format="json"
)
assert "auth_token" in json.loads(response.content)
token = Token.objects.get(user__username=user_data["username"])
cls.api_client.credentials(HTTP_AUTHORIZATION="Token " + token.key)
response = cls.api_client.get("/api/users/me/")
assert response.status_code == status.HTTP_200_OK
Loading

0 comments on commit a384a87

Please sign in to comment.