From 56db5fe8b4e337c0c01fe1b12acd7bbfb29aff74 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Fri, 19 Jul 2024 16:53:29 +0200 Subject: [PATCH 01/47] Add some additional basic nutritional values validation for ingredients --- wger/nutrition/consts.py | 6 +- wger/nutrition/dataclasses.py | 40 ++++++++ wger/nutrition/models/ingredient.py | 48 +-------- wger/nutrition/models/plan.py | 4 +- wger/nutrition/tests/test_dataclass.py | 130 ++++++++++++++++++++++++ wger/nutrition/tests/test_ingredient.py | 43 ++------ wger/nutrition/tests/test_off.py | 16 +-- 7 files changed, 189 insertions(+), 98 deletions(-) create mode 100644 wger/nutrition/tests/test_dataclass.py diff --git a/wger/nutrition/consts.py b/wger/nutrition/consts.py index d5c4f2d952..27dac47e3e 100644 --- a/wger/nutrition/consts.py +++ b/wger/nutrition/consts.py @@ -18,9 +18,9 @@ MEALITEM_WEIGHT_UNIT = '2' ENERGY_FACTOR = { - 'protein': {'kg': 4, 'lb': 113}, - 'carbohydrates': {'kg': 4, 'lb': 113}, - 'fat': {'kg': 9, 'lb': 225}, + 'protein': {'metric': 4, 'imperial': 113}, + 'carbohydrates': {'metric': 4, 'imperial': 113}, + 'fat': {'metric': 9, 'imperial': 225}, } """ Simple approximation of energy (kcal) provided per gram or ounce diff --git a/wger/nutrition/dataclasses.py b/wger/nutrition/dataclasses.py index d87408b9f2..9f81f10952 100644 --- a/wger/nutrition/dataclasses.py +++ b/wger/nutrition/dataclasses.py @@ -19,6 +19,9 @@ ) from typing import Optional +# wger +from wger.nutrition.consts import ENERGY_FACTOR + @dataclass class IngredientData: @@ -50,6 +53,7 @@ def sanity_checks(self): self.brand = self.brand[:200] self.common_name = self.common_name[:200] + # Mass checks (not more than 100g of something per 100g of product etc) macros = [ 'protein', 'fat', @@ -64,8 +68,44 @@ def sanity_checks(self): if value and value > 100: raise ValueError(f'Value for {macro} is greater than 100: {value}') + if self.fat_saturated and self.fat_saturated > self.fat: + raise ValueError( + f'Saturated fat is greater than fat: {self.fat_saturated} > {self.fat}' + ) + + if self.carbohydrates_sugar and self.carbohydrates_sugar > self.carbohydrates: + raise ValueError( + f'Sugar is greater than carbohydrates: {self.carbohydrates_sugar} > {self.carbohydrates}' + ) + if self.carbohydrates + self.protein + self.fat > 100: raise ValueError(f'Total of carbohydrates, protein and fat is greater than 100!') + # Energy approximations + energy_protein = self.protein * ENERGY_FACTOR['protein']['metric'] + energy_carbohydrates = self.carbohydrates * ENERGY_FACTOR['carbohydrates']['metric'] + energy_fat = self.fat * ENERGY_FACTOR['fat']['metric'] + energy_calculated = energy_protein + energy_carbohydrates + energy_fat + + if energy_fat > self.energy: + raise ValueError( + f'Energy calculated from fat is greater than total energy: {energy_fat} > {self.energy}' + ) + + if energy_carbohydrates > self.energy: + raise ValueError( + f'Energy calculated from carbohydrates is greater than total energy: {energy_carbohydrates} > {self.energy}' + ) + + if energy_protein > self.energy: + raise ValueError( + f'Energy calculated from protein is greater than total energy: {energy_protein} > {self.energy}' + ) + + if energy_calculated > self.energy: + raise ValueError( + f'Total energy calculated is greater than energy: {energy_calculated} > {self.energy}' + ) + def dict(self): return asdict(self) diff --git a/wger/nutrition/models/ingredient.py b/wger/nutrition/models/ingredient.py index fbc3251049..572bc038be 100644 --- a/wger/nutrition/models/ingredient.py +++ b/wger/nutrition/models/ingredient.py @@ -46,10 +46,7 @@ # wger from wger.core.models import Language -from wger.nutrition.consts import ( - ENERGY_FACTOR, - KJ_PER_KCAL, -) +from wger.nutrition.consts import KJ_PER_KCAL from wger.nutrition.managers import ApproximateCountManager from wger.nutrition.models.ingredient_category import IngredientCategory from wger.nutrition.models.sources import Source @@ -265,49 +262,6 @@ def get_absolute_url(self): else: return reverse('nutrition:ingredient:view', kwargs={'pk': self.id, 'slug': slug}) - def clean(self): - """ - Do a very broad sanity check on the nutritional values according to - the following rules: - - 1g of protein: 4kcal - - 1g of carbohydrates: 4kcal - - 1g of fat: 9kcal - - The sum is then compared to the given total energy, with ENERGY_APPROXIMATION - percent tolerance. - """ - - # Note: calculations in 100 grams, to save us the '/100' everywhere - energy_protein = 0 - if self.protein: - energy_protein = self.protein * ENERGY_FACTOR['protein']['kg'] - - energy_carbohydrates = 0 - if self.carbohydrates: - energy_carbohydrates = self.carbohydrates * ENERGY_FACTOR['carbohydrates']['kg'] - - energy_fat = 0 - if self.fat: - # TODO: for some reason, during the tests the fat value is not - # converted to decimal (django 1.9) - energy_fat = Decimal(self.fat * ENERGY_FACTOR['fat']['kg']) - - energy_calculated = energy_protein + energy_carbohydrates + energy_fat - - # Compare the values, but be generous - if self.energy: - energy_upper = self.energy * (1 + (self.ENERGY_APPROXIMATION / Decimal(100.0))) - energy_lower = self.energy * (1 - (self.ENERGY_APPROXIMATION / Decimal(100.0))) - - if not ((energy_upper > energy_calculated) and (energy_calculated > energy_lower)): - raise ValidationError( - _( - f'The total energy ({self.energy}kcal) is not the approximate sum of the ' - f'energy provided by protein, carbohydrates and fat ({energy_calculated}kcal' - f' +/-{self.ENERGY_APPROXIMATION}%)' - ) - ) - def save(self, *args, **kwargs): """ Reset the cache diff --git a/wger/nutrition/models/plan.py b/wger/nutrition/models/plan.py index 8cea374fb6..8e884181e1 100644 --- a/wger/nutrition/models/plan.py +++ b/wger/nutrition/models/plan.py @@ -17,7 +17,6 @@ # Standard Library import datetime import logging -from decimal import Decimal # Django from django.contrib.auth.models import User @@ -30,7 +29,6 @@ from wger.nutrition.consts import ENERGY_FACTOR from wger.nutrition.helpers import NutritionalValues from wger.utils.cache import cache_mapper -from wger.utils.constants import TWOPLACES from wger.weight.models import WeightEntry @@ -121,7 +119,7 @@ def get_nutritional_values(self): if not nutritional_representation: nutritional_values = NutritionalValues() use_metric = self.user.userprofile.use_metric - unit = 'kg' if use_metric else 'lb' + unit = 'metric' if use_metric else 'imperial' result = { 'total': NutritionalValues(), 'percent': {'protein': 0, 'carbohydrates': 0, 'fat': 0}, diff --git a/wger/nutrition/tests/test_dataclass.py b/wger/nutrition/tests/test_dataclass.py new file mode 100644 index 0000000000..e838bce667 --- /dev/null +++ b/wger/nutrition/tests/test_dataclass.py @@ -0,0 +1,130 @@ +# This file is part of wger Workout Manager. +# +# wger Workout Manager is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# wger Workout Manager is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Workout Manager. If not, see . + +# Django +from django.test import SimpleTestCase + +# wger +from wger.nutrition.dataclasses import IngredientData +from wger.utils.constants import CC_0_LICENSE_ID + + +class IngredientDataclassTestCase(SimpleTestCase): + """ + Test validation rules + """ + + ingredient_data: IngredientData + + def setUp(self): + self.ingredient_data = IngredientData( + name='Foo With Chocolate', + remote_id='1234567', + language_id=1, + energy=166.0, + protein=32.1, + carbohydrates=0.0, + carbohydrates_sugar=None, + fat=3.24, + fat_saturated=None, + fiber=None, + sodium=None, + code=None, + source_name='USDA', + source_url='', + common_name='', + brand='', + license_id=CC_0_LICENSE_ID, + license_author='', + license_title='', + license_object_url='', + ) + + def test_validation_ok(self): + """""" + self.assertEqual(self.ingredient_data.sanity_checks(), None) + + def test_validation_bigger_100(self): + """ + Test the validation for values bigger than 100 + """ + self.ingredient_data.protein = 101 + self.assertRaises(ValueError, self.ingredient_data.sanity_checks) + + def test_validation_saturated_fat(self): + """ + Test the validation for saturated fat + """ + self.ingredient_data.fat = 20 + self.ingredient_data.fat_saturated = 30 + self.assertRaises(ValueError, self.ingredient_data.sanity_checks) + + def test_validation_sugar(self): + """ + Test the validation for sugar + """ + self.ingredient_data.carbohydrates = 20 + self.ingredient_data.carbohydrates_sugar = 30 + self.assertRaises(ValueError, self.ingredient_data.sanity_checks) + + def test_validation_energy_fat(self): + """ + Test the validation for energy and fat + """ + self.ingredient_data.energy = 200 + self.ingredient_data.fat = 30 # generates 30 * 9 = 270 kcal + self.assertRaisesRegex( + ValueError, + 'Energy calculated from fat', + self.ingredient_data.sanity_checks, + ) + + def test_validation_energy_protein(self): + """ + Test the validation for energy and protein + """ + self.ingredient_data.energy = 100 + self.ingredient_data.protein = 30 # generates 30 * 4 = 120 kcal + self.assertRaisesRegex( + ValueError, + 'Energy calculated from protein', + self.ingredient_data.sanity_checks, + ) + + def test_validation_energy_carbohydrates(self): + """ + Test the validation for energy and carbohydrates + """ + self.ingredient_data.energy = 100 + self.ingredient_data.carbohydrates = 30 # generates 30 * 4 = 120 kcal + self.assertRaisesRegex( + ValueError, + 'Energy calculated from carbohydrates', + self.ingredient_data.sanity_checks, + ) + + def test_validation_energy_total(self): + """ + Test the validation for energy total + """ + self.ingredient_data.energy = 200 # less than 120 + 80 + 90 + self.ingredient_data.protein = 30 # generates 30 * 4 = 120 kcal + self.ingredient_data.carbohydrates = 20 # generates 20 * 4 = 80 kcal + self.ingredient_data.fat = 10 # generates 10 * 9 = 90 kcal + self.assertRaisesRegex( + ValueError, + 'Total energy calculated', + self.ingredient_data.sanity_checks, + ) diff --git a/wger/nutrition/tests/test_ingredient.py b/wger/nutrition/tests/test_ingredient.py index 5176d17495..dbd39ea0b8 100644 --- a/wger/nutrition/tests/test_ingredient.py +++ b/wger/nutrition/tests/test_ingredient.py @@ -396,38 +396,6 @@ def test_compare(self): meal = Meal.objects.get(pk=1) self.assertFalse(ingredient1 == meal) - def test_total_energy(self): - """ - Tests the custom clean() method - """ - self.user_login('admin') - - # Values OK - ingredient = Ingredient() - ingredient.name = 'FooBar, cooked, with salt' - ingredient.energy = 50 - ingredient.protein = 0.5 - ingredient.carbohydrates = 12 - ingredient.fat = Decimal('0.1') - ingredient.language_id = 1 - self.assertFalse(ingredient.full_clean()) - - # Values wrong - ingredient.protein = 20 - self.assertRaises(ValidationError, ingredient.full_clean) - - ingredient.protein = 0.5 - ingredient.fat = 5 - self.assertRaises(ValidationError, ingredient.full_clean) - - ingredient.fat = 0.1 - ingredient.carbohydrates = 20 - self.assertRaises(ValidationError, ingredient.full_clean) - - ingredient.fat = 5 - ingredient.carbohydrates = 20 - self.assertRaises(ValidationError, ingredient.full_clean) - class IngredientApiTestCase(api_base_test.ApiBaseResourceTestCase): """ @@ -451,15 +419,16 @@ def setUp(self): self.off_response = { 'code': '1234', 'lang': 'de', + 'name': 'Foo with chocolate', 'product_name': 'Foo with chocolate', 'generic_name': 'Foo with chocolate, 250g package', 'brands': 'The bar company', 'editors_tags': ['open food facts', 'MrX'], 'nutriments': { - 'energy-kcal_100g': 120, + 'energy-kcal_100g': 600, 'proteins_100g': 10, - 'carbohydrates_100g': 20, - 'sugars_100g': 30, + 'carbohydrates_100g': 30, + 'sugars_100g': 20, 'fat_100g': 40, 'saturated-fat_100g': 11, 'sodium_100g': 5, @@ -480,9 +449,9 @@ def test_fetch_from_off_success(self, mock_api): self.assertEqual(ingredient.name, 'Foo with chocolate') self.assertEqual(ingredient.code, '1234') - self.assertEqual(ingredient.energy, 120) + self.assertEqual(ingredient.energy, 600) self.assertEqual(ingredient.protein, 10) - self.assertEqual(ingredient.carbohydrates, 20) + self.assertEqual(ingredient.carbohydrates, 30) self.assertEqual(ingredient.fat, 40) self.assertEqual(ingredient.fat_saturated, 11) self.assertEqual(ingredient.sodium, 5) diff --git a/wger/nutrition/tests/test_off.py b/wger/nutrition/tests/test_off.py index a4a5fe71cc..6125aa1395 100644 --- a/wger/nutrition/tests/test_off.py +++ b/wger/nutrition/tests/test_off.py @@ -38,10 +38,10 @@ def setUp(self): 'brands': 'The bar company', 'editors_tags': ['open food facts', 'MrX'], 'nutriments': { - 'energy-kcal_100g': 120, + 'energy-kcal_100g': 600, 'proteins_100g': 10, - 'carbohydrates_100g': 20, - 'sugars_100g': 30, + 'carbohydrates_100g': 30, + 'sugars_100g': 20, 'fat_100g': 40, 'saturated-fat_100g': 11, 'sodium_100g': 5, @@ -59,10 +59,10 @@ def test_regular_response(self): name='Foo with chocolate', remote_id='1234', language_id=1, - energy=120, + energy=600, protein=10, - carbohydrates=20, - carbohydrates_sugar=30, + carbohydrates=30, + carbohydrates_sugar=20, fat=40, fat_saturated=11, fiber=None, @@ -86,12 +86,12 @@ def test_convert_kj(self): we convert it to kcal per 100 g """ del self.off_data1['nutriments']['energy-kcal_100g'] - self.off_data1['nutriments']['energy-kj_100g'] = 120 + self.off_data1['nutriments']['energy-kj_100g'] = 2510.4 result = extract_info_from_off(self.off_data1, 1) # 120 / KJ_PER_KCAL - self.assertAlmostEqual(result.energy, 28.6806, 3) + self.assertAlmostEqual(result.energy, 600, 3) def test_no_energy(self): """ From 9606d5fec0bb8a5232601809bf8cc762391bd5bb Mon Sep 17 00:00:00 2001 From: Mannai <> Date: Wed, 10 Dec 2025 00:55:11 +0300 Subject: [PATCH 02/47] Replace bleach with nh3 and implement Markdown support for exercises - Remove dependency (deprecated). - Add and dependencies. - Add field to ExerciseTranslation model. - Update API serializers to handle markdown input. - Create for rendering and sanitization. --- pyproject.toml | 4 +- wger/exercises/api/serializers.py | 3 + wger/exercises/api/views.py | 23 +----- wger/exercises/models/translation.py | 15 +++- wger/utils/generic_views.py | 19 ++--- wger/utils/markdown.py | 51 ++++++++++++ wger/utils/tests/test_markdown.py | 112 +++++++++++++++++++++++++++ 7 files changed, 189 insertions(+), 38 deletions(-) create mode 100644 wger/utils/markdown.py create mode 100644 wger/utils/tests/test_markdown.py diff --git a/pyproject.toml b/pyproject.toml index 37e910cf88..a9f90b5337 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,6 @@ classifiers = [ ] dependencies = [ - "bleach[css]~=6.3", "celery[redis]~=5.5.3", "crispy-bootstrap5==2025.6", "django-activity-stream~=2.0.0", @@ -58,6 +57,9 @@ dependencies = [ "icalendar~=6.3.2", "invoke~=2.2.1", "lingua-language-detector~=2.1.1", + "markdownify~=1.2", + "markdown-it-py~=4.0", + "nh3~=0.3", "openfoodfacts~=3.3.0", "packaging~=25.0", "pillow~=12.0.0", diff --git a/wger/exercises/api/serializers.py b/wger/exercises/api/serializers.py index be6750a04e..7c4cee4ce9 100644 --- a/wger/exercises/api/serializers.py +++ b/wger/exercises/api/serializers.py @@ -468,6 +468,7 @@ class ExerciseTranslationSerializer(serializers.ModelSerializer): id = serializers.IntegerField(required=False, read_only=True) uuid = serializers.UUIDField(required=False, read_only=True) + description_source = serializers.CharField(required=False, allow_blank=True) exercise = serializers.PrimaryKeyRelatedField( queryset=Exercise.objects.all(), required=True, @@ -481,10 +482,12 @@ class Meta: 'name', 'exercise', 'description', + 'description_source', 'created', 'language', 'license_author', ) + read_only_fields = ('description') # Prevents API from accepting raw HTML def validate(self, value): """ diff --git a/wger/exercises/api/views.py b/wger/exercises/api/views.py index 5739904b3c..db71a798c9 100644 --- a/wger/exercises/api/views.py +++ b/wger/exercises/api/views.py @@ -29,9 +29,7 @@ from django.views.decorators.cache import cache_page # Third Party -import bleach from actstream import action as actstream_action -from bleach.css_sanitizer import CSSSanitizer from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import ( OpenApiParameter, @@ -173,15 +171,7 @@ def perform_create(self, serializer): """ Save entry to activity stream """ - # Clean the description HTML - if serializer.validated_data.get('description'): - serializer.validated_data['description'] = bleach.clean( - serializer.validated_data['description'], - tags=HTML_TAG_WHITELIST, - attributes=HTML_ATTRIBUTES_WHITELIST, - css_sanitizer=CSSSanitizer(allowed_css_properties=HTML_STYLES_WHITELIST), - strip=True, - ) + super().perform_create(serializer) actstream_action.send( @@ -202,17 +192,8 @@ def perform_update(self, serializer): if serializer.validated_data.get('language'): del serializer.validated_data['language'] - # Clean the description HTML - if serializer.validated_data.get('description'): - serializer.validated_data['description'] = bleach.clean( - serializer.validated_data['description'], - tags=HTML_TAG_WHITELIST, - attributes=HTML_ATTRIBUTES_WHITELIST, - css_sanitizer=CSSSanitizer(allowed_css_properties=HTML_STYLES_WHITELIST), - strip=True, - ) - super().perform_update(serializer) + actstream_action.send( self.request.user, verb=StreamVerbs.UPDATED.value, diff --git a/wger/exercises/models/translation.py b/wger/exercises/models/translation.py index 97efbd1da1..2867cce442 100644 --- a/wger/exercises/models/translation.py +++ b/wger/exercises/models/translation.py @@ -26,12 +26,13 @@ from django.utils.translation import gettext_lazy as _ # Third Party -import bleach +import nh3 from simple_history.models import HistoricalRecords # wger from wger.core.models import Language from wger.exercises.models import Exercise +from wger.utils.markdown import render_markdown, sanitize_html from wger.utils.cache import reset_exercise_api_cache from wger.utils.models import ( AbstractHistoryMixin, @@ -51,6 +52,13 @@ class Translation(AbstractLicenseModel, AbstractHistoryMixin, models.Model): ) """Description on how to perform the exercise""" + description_source = models.TextField( + verbose_name=_('Description (Source)'), + blank=True, + null=True + ) + """The raw Markdown source""" + name = models.CharField( max_length=200, verbose_name=_('Name'), @@ -128,6 +136,9 @@ def save(self, *args, **kwargs): """ Reset all cached infos """ + if self.description_source: + self.description = render_markdown(self.description_source) + super().save(*args, **kwargs) # Api cache @@ -203,7 +214,7 @@ def description_clean(self): """ Return the exercise description with all markup removed """ - return bleach.clean(self.description, strip=True) + return sanitize_html(self.description) def get_owner_object(self): """ diff --git a/wger/utils/generic_views.py b/wger/utils/generic_views.py index 41d8a25bf9..b449e82561 100644 --- a/wger/utils/generic_views.py +++ b/wger/utils/generic_views.py @@ -31,8 +31,6 @@ from django.views.generic.edit import ModelFormMixin # Third Party -import bleach -from bleach.css_sanitizer import CSSSanitizer from crispy_forms.helper import FormHelper from crispy_forms.layout import ( ButtonHolder, @@ -41,6 +39,7 @@ ) # wger +from wger.utils.markdown import sanitize_html from wger.utils.constants import ( HTML_ATTRIBUTES_WHITELIST, HTML_STYLES_WHITELIST, @@ -147,7 +146,7 @@ class WgerFormMixin(ModelFormMixin): clean_html = () """ - List of form fields that should be passed to bleach to clean the html + List of form fields that should be passed to wger.utils.markdown to clean the html """ messages = '' @@ -237,17 +236,9 @@ def form_valid(self, form): """ for field in self.clean_html: - setattr( - form.instance, - field, - bleach.clean( - getattr(form.instance, field), - tags=HTML_TAG_WHITELIST, - attributes=HTML_ATTRIBUTES_WHITELIST, - css_sanitizer=CSSSanitizer(allowed_css_properties=HTML_STYLES_WHITELIST), - strip=True, - ), - ) + raw_value = getattr(form.instance, field) + clean_value = sanitize_html(raw_value) + setattr(form.instance, field, clean_value) if self.get_messages(): messages.success(self.request, self.get_messages()) diff --git a/wger/utils/markdown.py b/wger/utils/markdown.py new file mode 100644 index 0000000000..0ce59e5907 --- /dev/null +++ b/wger/utils/markdown.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- + +# This file is part of wger Workout Manager. +# +# wger Workout Manager is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# wger Workout Manager is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License + +# Third party +import nh3 +from markdown_it import MarkdownIt + +def render_markdown(text): + """ + Renders markdown text to HTML and sanitizes it to allow only basic markup. + """ + if not text: + return "" + + # Render Markdown to HTML + md = MarkdownIt("commonmark", {"breaks": True, "html": True}) + raw_html = md.render(text) + + # Sanitize HTML + ALLOWED_TAGS = {'b', 'strong', 'i', 'em', 'ul', 'ol', 'li', 'p'} + + clean_html = nh3.clean( + raw_html, + tags=ALLOWED_TAGS, + attributes={} + ) + + return clean_html + +def sanitize_html(text): + """ + Directly sanitizes HTML (for legacy fields or non-markdown inputs) + """ + if not text: + return "" + + ALLOWED_TAGS = {'b', 'strong', 'i', 'em', 'ul', 'ol', 'li', 'p'} + return nh3.clean(text, tags=ALLOWED_TAGS, attributes={}) \ No newline at end of file diff --git a/wger/utils/tests/test_markdown.py b/wger/utils/tests/test_markdown.py new file mode 100644 index 0000000000..d2643e9439 --- /dev/null +++ b/wger/utils/tests/test_markdown.py @@ -0,0 +1,112 @@ +# This file is part of wger Workout Manager. +# +# wger Workout Manager is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# wger Workout Manager is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License + +# Standard Library +import unittest + +# wger +from wger.utils.markdown import ( + render_markdown, + sanitize_html, +) + + +class TestMarkdown(unittest.TestCase): + """ + Test the markdown rendering and HTML sanitization utilities. + Allowed tags: b, strong, i, em, ul, ol, li, p + """ + + def test_render_markdown_basic(self): + """ + Test that basic markdown syntax is rendered to HTML. + """ + # Bold and Italic + text = "I like **osaka** and eating *sata-andagi*." + output = render_markdown(text) + + self.assertTrue('osaka' in output or 'osaka' in output) + self.assertTrue('sata-andagi' in output or 'sata-andagi' in output) + self.assertIn('

', output) + + def test_render_markdown_lists(self): + """ + Test that lists are rendered correctly. + """ + text = "- Item 1\n- Item 2" + output = render_markdown(text) + + self.assertIn('