Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/filters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub enum FilterType {
Capfirst(CapfirstFilter),
Center(CenterFilter),
Default(DefaultFilter),
DefaultIfNone(DefaultIfNoneFilter),
Escape(EscapeFilter),
Escapejs(EscapejsFilter),
External(ExternalFilter),
Expand Down Expand Up @@ -60,6 +61,17 @@ impl DefaultFilter {
}
}

#[derive(Clone, Debug, PartialEq)]
pub struct DefaultIfNoneFilter {
pub argument: Argument,
}

impl DefaultIfNoneFilter {
pub fn new(argument: Argument) -> Self {
Self { argument }
}
}

#[derive(Clone, Debug, PartialEq)]
pub struct EscapeFilter;

Expand Down
5 changes: 5 additions & 0 deletions src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use crate::filters::AddSlashesFilter;
use crate::filters::CapfirstFilter;
use crate::filters::CenterFilter;
use crate::filters::DefaultFilter;
use crate::filters::DefaultIfNoneFilter;
use crate::filters::EscapeFilter;
use crate::filters::EscapejsFilter;
use crate::filters::ExternalFilter;
Expand Down Expand Up @@ -127,6 +128,10 @@ impl Filter {
Some(right) => FilterType::Default(DefaultFilter::new(right)),
None => return Err(ParseError::MissingArgument { at: at.into() }),
},
"default_if_none" => match right {
Some(right) => FilterType::DefaultIfNone(DefaultIfNoneFilter::new(right)),
None => return Err(ParseError::MissingArgument { at: at.into() }),
},
"escape" => match right {
Some(right) => return Err(unexpected_argument("escape", right)),
None => FilterType::Escape(EscapeFilter),
Expand Down
26 changes: 23 additions & 3 deletions src/render/filters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ use pyo3::types::PyType;

use crate::error::{AnnotatePyErr, RenderError};
use crate::filters::{
AddFilter, AddSlashesFilter, CapfirstFilter, CenterFilter, DefaultFilter, EscapeFilter,
EscapejsFilter, ExternalFilter, FilterType, LowerFilter, SafeFilter, SlugifyFilter,
UpperFilter, YesnoFilter,
AddFilter, AddSlashesFilter, CapfirstFilter, CenterFilter, DefaultFilter, DefaultIfNoneFilter,
EscapeFilter, EscapejsFilter, ExternalFilter, FilterType, LowerFilter, SafeFilter,
SlugifyFilter, UpperFilter, YesnoFilter,
};
use crate::parse::Filter;
use crate::render::common::gettext;
Expand Down Expand Up @@ -47,6 +47,7 @@ impl Resolve for Filter {
FilterType::Capfirst(filter) => filter.resolve(left, py, template, context),
FilterType::Center(filter) => filter.resolve(left, py, template, context),
FilterType::Default(filter) => filter.resolve(left, py, template, context),
FilterType::DefaultIfNone(filter) => filter.resolve(left, py, template, context),
FilterType::Escape(filter) => filter.resolve(left, py, template, context),
FilterType::Escapejs(filter) => filter.resolve(left, py, template, context),
FilterType::External(filter) => filter.resolve(left, py, template, context),
Expand Down Expand Up @@ -256,6 +257,25 @@ impl ResolveFilter for DefaultFilter {
}
}

impl ResolveFilter for DefaultIfNoneFilter {
fn resolve<'t, 'py>(
&self,
variable: Option<Content<'t, 'py>>,
py: Python<'py>,
template: TemplateString<'t>,
context: &mut Context,
) -> ResolveResult<'t, 'py> {
match variable {
Some(Content::Py(ref value)) if value.is_none() => {
self.argument
.resolve(py, template, context, ResolveFailures::Raise)
}
Some(left) => Ok(Some(left)),
None => Ok(Some("".as_content())),
}
}
}

impl ResolveFilter for EscapeFilter {
fn resolve<'t, 'py>(
&self,
Expand Down
149 changes: 149 additions & 0 deletions tests/filters/test_default_if_none.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import pytest
from django.template.base import VariableDoesNotExist


@pytest.mark.parametrize(
"value,expected",
[
pytest.param(None, "default_value", id="none_value"),
pytest.param("", "", id="empty_string"),
pytest.param(0, "0", id="zero"),
pytest.param(False, "False", id="false"),
pytest.param("actual_value", "actual_value", id="string_value"),
pytest.param(42, "42", id="integer_value"),
pytest.param(3.14, "3.14", id="float_value"),
pytest.param(True, "True", id="true_value"),
pytest.param([], "[]", id="empty_list"),
pytest.param({}, "{}", id="empty_dict"),
],
)
def test_default_if_none_with_various_values(assert_render, value, expected):
template = "{{ value|default_if_none:'default_value' }}"
assert_render(template=template, context={"value": value}, expected=expected)


def test_default_if_none_with_missing_variable(assert_render):
template = "{{ missing|default_if_none:'default' }}"
assert_render(template=template, context={}, expected="")


@pytest.mark.parametrize(
"template,expected",
[
pytest.param(
"{{ value|default_if_none:'DEFAULT'|lower }}", "default", id="chained_lower"
),
pytest.param(
"{{ value|default_if_none:'default'|upper }}", "DEFAULT", id="chained_upper"
),
pytest.param(
"{{ value|default_if_none:'<b>default</b>'|safe }}",
"<b>default</b>",
id="chained_safe",
),
],
)
def test_default_if_none_chained_filters(assert_render, template, expected):
assert_render(template=template, context={"value": None}, expected=expected)


def test_default_if_none_preserves_html_safe_value(assert_render):
assert_render(
template="{{ value|safe|default_if_none:'default' }}",
context={"value": "<b>safe</b>"},
expected="<b>safe</b>",
)


@pytest.mark.parametrize(
"template,context,expected",
[
pytest.param(
"{% autoescape on %}{{ value|default_if_none:'default' }}{% endautoescape %}",
{"value": "<b>html</b>"},
"&lt;b&gt;html&lt;/b&gt;",
id="autoescape_html_value",
),
pytest.param(
"{% autoescape on %}{{ value|default_if_none:default }}{% endautoescape %}",
{"value": None, "default": "<b>default</b>"},
"&lt;b&gt;default&lt;/b&gt;",
id="autoescape_html_default_variable",
),
pytest.param(
"{% autoescape off %}{{ value|default_if_none:'default' }}{% endautoescape %}",
{"value": "<b>html</b>"},
"<b>html</b>",
id="autoescape_html_value",
),
pytest.param(
"{% autoescape off %}{{ value|default_if_none:default }}{% endautoescape %}",
{"value": None, "default": "<b>default</b>"},
"<b>default</b>",
id="autoescape_html_default_variable",
),
pytest.param(
"{{ value|default_if_none:'<b>default</b>'|escape }}",
{"value": None},
"<b>default</b>",
id="explicit_default_str_literal_never_escaped",
),
pytest.param(
"{{ value|default_if_none:default|escape }}",
{"value": None, "default": "<b>default</b>"},
"&lt;b&gt;default&lt;/b&gt;",
id="explicit_escape_html_default_variable",
),
pytest.param(
"{% autoescape on %}{{ value|default_if_none:default|safe }}{% endautoescape %}",
{"value": None, "default": "<b>default</b>"},
"<b>default</b>",
id="safe_prevents_escape_default",
),
],
)
def test_default_if_none_html_escaping(assert_render, template, context, expected):
assert_render(template=template, context=context, expected=expected)


def test_default_if_none_with_forloop_variable(assert_render):
template = "{% for x in items %}{{ x|default_if_none:forloop.counter }}{% endfor %}"
assert_render(
template=template, context={"items": [None, "a", None]}, expected="1a3"
)


def test_default_if_none_missing_argument(assert_parse_error):
template = "{{ value|default_if_none }}"
django_message = "default_if_none requires 2 arguments, 1 provided"
rusty_message = """\
× Expected an argument
╭────
1 │ {{ value|default_if_none }}
· ───────┬───────
· ╰── here
╰────
"""
assert_parse_error(
template=template, django_message=django_message, rusty_message=rusty_message
)


def test_default_if_none_missing_variable_argument(assert_render_error):
django_message = "Failed lookup for key [missing] in [{'True': True, 'False': False, 'None': None}, {'value': None}]"
rusty_message = """\
× Failed lookup for key [missing] in {"False": False, "None": None, "True":
│ True, "value": None}
╭────
1 │ {{ value|default_if_none:missing }}
· ───┬───
· ╰── key
╰────
"""
assert_render_error(
template="{{ value|default_if_none:missing }}",
context={"value": None},
exception=VariableDoesNotExist,
django_message=django_message,
rusty_message=rusty_message,
)
Loading