diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index db95c29ec..65c06c534 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,6 +20,10 @@ "ms-python.python" ] } + }, + + "containerEnv": { + "CSRF_TRUSTED_ORIGINS": "http://localhost:8000,http://localhost:8080" } // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. diff --git a/cookbook/schemas.py b/cookbook/schemas.py index 504ea9712..66bab3a8f 100644 --- a/cookbook/schemas.py +++ b/cookbook/schemas.py @@ -14,8 +14,11 @@ class QueryParam(object): class QueryParamAutoSchema(AutoSchema): + def is_query(self, path, method): + return is_list_view(path, method, self.view) + def get_path_parameters(self, path, method): - if not is_list_view(path, method, self.view): + if not self.is_query(path, method): return super().get_path_parameters(path, method) parameters = super().get_path_parameters(path, method) for q in self.view.query_params: diff --git a/cookbook/tests/api/test_api_meal_plan.py b/cookbook/tests/api/test_api_meal_plan.py index 3faebf19e..f94ecf1a4 100644 --- a/cookbook/tests/api/test_api_meal_plan.py +++ b/cookbook/tests/api/test_api_meal_plan.py @@ -5,12 +5,14 @@ import pytest from django.contrib import auth from django.urls import reverse from django_scopes import scope, scopes_disabled +from icalendar import Calendar from cookbook.models import MealPlan, MealType from cookbook.tests.factories import RecipeFactory LIST_URL = 'api:mealplan-list' DETAIL_URL = 'api:mealplan-detail' +ICAL_URL = 'api:mealplan-ical' # NOTE: auto adding shopping list from meal plan is tested in test_shopping_recipe as tests are identical @@ -32,6 +34,11 @@ def obj_2(space_1, recipe_1_s1, meal_type, u1_s1): return MealPlan.objects.create(recipe=recipe_1_s1, space=space_1, meal_type=meal_type, from_date=datetime.now(), to_date=datetime.now(), created_by=auth.get_user(u1_s1)) +@pytest.fixture +def obj_3(space_1, recipe_1_s1, meal_type, u1_s1): + return MealPlan.objects.create(recipe=recipe_1_s1, space=space_1, meal_type=meal_type, from_date=datetime.now() - timedelta(days=30), to_date=datetime.now() - timedelta(days=1), + created_by=auth.get_user(u1_s1)) + @pytest.mark.parametrize("arg", [ ['a_u', 403], @@ -163,3 +170,32 @@ def test_add_with_shopping(u1_s1, meal_type): ) assert len(json.loads(u1_s1.get(reverse('api:shoppinglistentry-list')).content)) == 10 + + +@pytest.mark.parametrize("arg", [ + [f'', 2], + [f'?from_date={datetime.now().strftime("%Y-%m-%d")}', 1], + [f'?to_date={(datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")}', 1], + [f'?from_date={(datetime.now() + timedelta(days=2)).strftime("%Y-%m-%d")}&to_date={(datetime.now() + timedelta(days=2)).strftime("%Y-%m-%d")}', 0], +]) +def test_ical(arg, request, obj_1, obj_3, u1_s1): + r = u1_s1.get(f'{reverse(ICAL_URL)}{arg[0]}') + assert r.status_code == 200 + cal = Calendar.from_ical(r.getvalue().decode('UTF-8')) + events = cal.walk('VEVENT') + assert len(events) == arg[1] + + +def test_ical_event(obj_1, u1_s1): + r = u1_s1.get(f'{reverse(ICAL_URL)}') + + cal = Calendar.from_ical(r.getvalue().decode('UTF-8')) + events = cal.walk('VEVENT') + assert len(events) == 1 + + event = events[0] + assert int(event['uid']) == obj_1.id + assert event['summary'] == f'{obj_1.meal_type.name}: {obj_1.get_label()}' + assert event['description'] == obj_1.note + assert event.decoded('dtstart') == datetime.now().date() + assert event.decoded('dtend') == datetime.now().date() \ No newline at end of file diff --git a/cookbook/tests/api/test_api_plan_ical.py b/cookbook/tests/api/test_api_plan_ical.py index c98278a1d..305ff0594 100644 --- a/cookbook/tests/api/test_api_plan_ical.py +++ b/cookbook/tests/api/test_api_plan_ical.py @@ -1,88 +1,55 @@ -# from datetime import datetime, timedelta -# -# import pytest -# from django.contrib import auth -# from django.urls import reverse -# from icalendar import Calendar -# -# from cookbook.models import MealPlan, MealType -# -# BOUND_URL = 'api_get_plan_ical' -# FROM_URL = 'api_get_plan_ical' -# FUTURE_URL = 'api_get_plan_ical' -# -# -# @pytest.fixture() -# def meal_type(space_1, u1_s1): -# return MealType.objects.get_or_create(name='test', space=space_1, created_by=auth.get_user(u1_s1))[0] -# -# -# @pytest.fixture() -# def obj_1(space_1, recipe_1_s1, meal_type, u1_s1): -# return MealPlan.objects.create(recipe=recipe_1_s1, space=space_1, meal_type=meal_type, from_date=datetime.now(), to_date=datetime.now(), -# created_by=auth.get_user(u1_s1)) -# -# -# @pytest.fixture -# def obj_2(space_1, recipe_1_s1, meal_type, u1_s1): -# return MealPlan.objects.create(recipe=recipe_1_s1, space=space_1, meal_type=meal_type, from_date=datetime.now()+timedelta(days=30), to_date=datetime.now()+timedelta(days=30), -# created_by=auth.get_user(u1_s1)) -# -# @pytest.fixture -# def obj_3(space_1, recipe_1_s1, meal_type, u1_s1): -# return MealPlan.objects.create(recipe=recipe_1_s1, space=space_1, meal_type=meal_type, from_date=datetime.now()+timedelta(days=-30), to_date=datetime.now()+timedelta(days=-1), -# created_by=auth.get_user(u1_s1)) -# -# -# @pytest.mark.parametrize("arg", [ -# ['a_u', 403], -# ['g1_s1', 403], -# ['u1_s1', 200], -# ['a1_s1', 200], -# ]) -# def test_permissions(arg, request): -# c = request.getfixturevalue(arg[0]) -# assert c.get(reverse(FUTURE_URL)).status_code == arg[1] -# -# def test_future(obj_1, obj_2, obj_3, u1_s1): -# r = u1_s1.get(reverse(FUTURE_URL)) -# assert r.status_code == 200 -# -# cal = Calendar.from_ical(r.getvalue().decode('UTF-8')) -# events = cal.walk('VEVENT') -# assert len(events) == 2 -# -# def test_from(obj_1, obj_2, obj_3, u1_s1): -# from_date_slug = (datetime.now()+timedelta(days=1)).strftime("%Y-%m-%d") -# r = u1_s1.get(reverse(FROM_URL, kwargs={'from_date': from_date_slug})) -# assert r.status_code == 200 -# -# cal = Calendar.from_ical(r.getvalue().decode('UTF-8')) -# events = cal.walk('VEVENT') -# assert len(events) == 1 -# -# def test_bound(obj_1, obj_2, obj_3, u1_s1): -# from_date_slug = (datetime.now()+timedelta(days=-1)).strftime("%Y-%m-%d") -# to_date_slug = (datetime.now()+timedelta(days=1)).strftime("%Y-%m-%d") -# r = u1_s1.get(reverse(BOUND_URL, kwargs={'from_date': from_date_slug, 'to_date': to_date_slug})) -# assert r.status_code == 200 -# -# cal = Calendar.from_ical(r.getvalue().decode('UTF-8')) -# events = cal.walk('VEVENT') -# assert len(events) == 1 -# -# def test_event(obj_1, u1_s1): -# from_date_slug = (datetime.now()+timedelta(days=-1)).strftime("%Y-%m-%d") -# to_date_slug = (datetime.now()+timedelta(days=1)).strftime("%Y-%m-%d") -# r = u1_s1.get(reverse(BOUND_URL, kwargs={'from_date': from_date_slug, 'to_date': to_date_slug})) -# -# cal = Calendar.from_ical(r.getvalue().decode('UTF-8')) -# events = cal.walk('VEVENT') -# assert len(events) == 1 -# -# event = events[0] -# assert int(event['uid']) == obj_1.id -# assert event['summary'] == f'{obj_1.meal_type.name}: {obj_1.get_label()}' -# assert event['description'] == obj_1.note -# assert event.decoded('dtstart') == datetime.now().date() -# assert event.decoded('dtend') == datetime.now().date() +from datetime import datetime, timedelta + +import pytest +from django.contrib import auth +from django.urls import reverse +from icalendar import Calendar + +from cookbook.models import MealPlan, MealType + +BOUND_URL = 'api_get_plan_ical' + + +@pytest.fixture() +def meal_type(space_1, u1_s1): + return MealType.objects.get_or_create(name='test', space=space_1, created_by=auth.get_user(u1_s1))[0] + + +@pytest.fixture() +def obj_1(space_1, recipe_1_s1, meal_type, u1_s1): + return MealPlan.objects.create(recipe=recipe_1_s1, space=space_1, meal_type=meal_type, from_date=datetime.now(), to_date=datetime.now(), + created_by=auth.get_user(u1_s1)) + + +@pytest.fixture +def obj_2(space_1, recipe_1_s1, meal_type, u1_s1): + return MealPlan.objects.create(recipe=recipe_1_s1, space=space_1, meal_type=meal_type, from_date=datetime.now()+timedelta(days=30), to_date=datetime.now()+timedelta(days=30), + created_by=auth.get_user(u1_s1)) + +@pytest.fixture +def obj_3(space_1, recipe_1_s1, meal_type, u1_s1): + return MealPlan.objects.create(recipe=recipe_1_s1, space=space_1, meal_type=meal_type, from_date=datetime.now()+timedelta(days=-30), to_date=datetime.now()+timedelta(days=-1), + created_by=auth.get_user(u1_s1)) + + +@pytest.mark.parametrize("arg", [ + ['a_u', 403], + ['g1_s1', 403], + ['u1_s1', 200], + ['a1_s1', 200], +]) +def test_permissions(arg, request): + c = request.getfixturevalue(arg[0]) + from_date_slug = (datetime.now()+timedelta(days=-1)).strftime("%Y-%m-%d") + to_date_slug = (datetime.now()+timedelta(days=1)).strftime("%Y-%m-%d") + assert c.get(reverse(BOUND_URL, kwargs={'from_date': from_date_slug, 'to_date': to_date_slug})).status_code == arg[1] + +def test_bound(obj_1, obj_2, obj_3, u1_s1): + from_date_slug = (datetime.now()+timedelta(days=-1)).strftime("%Y-%m-%d") + to_date_slug = (datetime.now()+timedelta(days=1)).strftime("%Y-%m-%d") + r = u1_s1.get(reverse(BOUND_URL, kwargs={'from_date': from_date_slug, 'to_date': to_date_slug})) + assert r.status_code == 200 + + cal = Calendar.from_ical(r.getvalue().decode('UTF-8')) + events = cal.walk('VEVENT') + assert len(events) == 1 diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 9c0f96e22..66aee94e4 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -33,6 +33,7 @@ from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext as _ from django_scopes import scopes_disabled +from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema, OpenApiParameter, extend_schema_view from icalendar import Calendar, Event from oauth2_provider.models import AccessToken @@ -41,7 +42,7 @@ from recipe_scrapers._exceptions import NoSchemaFoundInWildMode from requests.exceptions import MissingSchema from rest_framework import decorators, status, viewsets from rest_framework.authtoken.views import ObtainAuthToken -from rest_framework.decorators import api_view, permission_classes +from rest_framework.decorators import action, api_view, permission_classes from rest_framework.exceptions import APIException, PermissionDenied from rest_framework.pagination import PageNumberPagination from rest_framework.parsers import MultiPartParser @@ -765,13 +766,19 @@ class RecipeBookEntryViewSet(viewsets.ModelViewSet, viewsets.GenericViewSet): return queryset +MealPlanViewQueryParameters = [ + OpenApiParameter(name='from_date', description=_('Filter meal plans from date (inclusive) in the format of YYYY-MM-DD.'), type=str), + OpenApiParameter(name='to_date', description=_('Filter meal plans to date (inclusive) in the format of YYYY-MM-DD.'), type=str), + OpenApiParameter(name='meal_type', description=_('Filter meal plans with MealType ID. For multiple repeat parameter.'), type=str), +] + @extend_schema_view( list=extend_schema( - parameters=[ - OpenApiParameter(name='from_date', description=_('Filter meal plans from date (inclusive) in the format of YYYY-MM-DD.'), type=str), - OpenApiParameter(name='to_date', description=_('Filter meal plans to date (inclusive) in the format of YYYY-MM-DD.'), type=str), - OpenApiParameter(name='meal_type', description=_('Filter meal plans with MealType ID. For multiple repeat parameter.'), type=str), - ] + parameters= MealPlanViewQueryParameters + ), + ical=extend_schema( + parameters=MealPlanViewQueryParameters, + responses={(200, 'text/calendar'): OpenApiTypes.STR} ) ) class MealPlanViewSet(viewsets.ModelViewSet): @@ -804,6 +811,13 @@ class MealPlanViewSet(viewsets.ModelViewSet): queryset = queryset.filter(meal_type__in=meal_type) return queryset + + @action(detail=False) + def ical(self, request): + from_date = self.request.query_params.get('from_date', None) + to_date = self.request.query_params.get('to_date', None) + return meal_plans_to_ical(self.get_queryset(), f'meal_plan_{from_date}-{to_date}.ics') + class AutoPlanViewSet(viewsets.ViewSet): @@ -1786,6 +1800,9 @@ def get_plan_ical(request, from_date=datetime.date.today(), to_date=None): if to_date is not None: queryset = queryset.filter(to_date__lte=to_date) + return meal_plans_to_ical(queryset, f'meal_plan_{from_date}-{to_date}.ics') + +def meal_plans_to_ical(queryset, filename): cal = Calendar() for p in queryset: @@ -1801,7 +1818,7 @@ def get_plan_ical(request, from_date=datetime.date.today(), to_date=None): cal.add_component(event) response = FileResponse(io.BytesIO(cal.to_ical())) - response["Content-Disposition"] = f'attachment; filename=meal_plan_{from_date}-{to_date}.ics' # noqa: E501 + response["Content-Disposition"] = f'attachment; filename={filename}' # noqa: E501 return response diff --git a/requirements.txt b/requirements.txt index 5dac7957f..d0fbf1516 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,4 +55,4 @@ pytest-cov===4.1.0 pytest-factoryboy==2.6.0 pytest-html==4.1.1 pytest-asyncio==0.23.5 - +pytest-xdist==3.5.0 \ No newline at end of file