diff --git a/AGENTS.md b/AGENTS.md index 75b64a0..b815c70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,7 +218,7 @@ labhelper/ │ │ ├── search.html # Search page with AJAX │ │ └── thing_detail.html # Thing details view with tags │ ├── templatetags/ -│ │ └── dict_extras.py # Custom template filter: get_item +│ │ └── dict_extras.py # Custom template filters: get_item, render_markdown, truncate_markdown │ ├── admin.py # Admin configuration │ ├── apps.py # App configuration │ ├── forms.py # All forms and formsets @@ -260,7 +260,7 @@ labhelper/ | **Box** | A storage box in the lab | `id` (CharField PK, max 10), `box_type` (FK) | | **Facet** | A category of tags (e.g., Priority, Category) | `name`, `slug`, `color`, `cardinality` (single/multiple) | | **Tag** | A tag value for a specific facet | `facet` (FK), `name` | -| **Thing** | An item stored in a box | `name`, `box` (FK), `description`, `picture`, `tags` (M2M) | +| **Thing** | An item stored in a box | `name`, `box` (FK), `description` (Markdown), `picture`, `tags` (M2M) | | **ThingFile** | File attachment for a Thing | `thing` (FK), `file`, `title`, `uploaded_at` | | **ThingLink** | Hyperlink for a Thing | `thing` (FK), `url`, `title`, `uploaded_at` | @@ -397,41 +397,86 @@ The project uses a base template system at `labhelper/templates/base.html`. All ### Template Best Practices 1. **Always extend base template** - ```django - {% extends "base.html" %} - ``` + ```django + {% extends "base.html" %} + ``` 2. **Use block system for content injection** - - `title`: Page title tag - - `page_header`: Page header with breadcrumbs - - `content`: Main page content - - `extra_css`: Additional styles - - `extra_js`: Additional JavaScript + - `title`: Page title tag + - `page_header`: Page header with breadcrumbs + - `content`: Main page content + - `extra_css`: Additional styles + - `extra_js`: Additional JavaScript 3. **Load required template tags** - ```django - {% load static %} - {% load mptt_tags %} - {% load thumbnail %} - ``` + ```django + {% load static %} + {% load mptt_tags %} + {% load thumbnail %} + {% load dict_extras %} + ``` 4. **Use URL names for links** - ```django - - ``` + ```django + + ``` 5. **Use icons with Font Awesome** - ```django - - ``` + ```django + + ``` 6. **Add breadcrumbs for navigation** - ```django - - ``` + ```django + + ``` + +### Markdown Support + +The `Thing.description` field supports Markdown formatting with HTML sanitization for security. + +**Available Template Filters:** + +- `render_markdown`: Converts Markdown text to sanitized HTML with automatic link handling + - Converts Markdown syntax (headers, lists, bold, italic, links, code, tables, etc.) + - Sanitizes HTML using `bleach` to prevent XSS attacks + - Automatically adds `target="_blank"` and `rel="noopener noreferrer"` to external links + - Use in `thing_detail.html` for full rendered Markdown + +- `truncate_markdown`: Converts Markdown to plain text and truncates + - Strips HTML tags after Markdown conversion + - Adds ellipsis (`...`) if text exceeds specified length (default: 100) + - Use in `box_detail.html` or search API previews where space is limited + +**Usage Examples:** +```django + +
+ {{ thing.description|render_markdown }} +
+ + +{{ thing.description|truncate_markdown:100 }} +``` + +**Supported Markdown Features:** +- Bold: `**text**` or `__text__` +- Italic: `*text*` or `_text_` +- Headers: `# Header 1`, `## Header 2`, etc. +- Lists: `- item` or `1. item` +- Links: `[text](url)` +- Code: `` `code` `` or ` ```code block``` +- Blockquotes: `> quote` +- Tables: `| A | B |\n|---|---|` + +**Security:** +- All Markdown is sanitized before rendering +- Dangerous HTML tags (`') + self.assertNotIn('', result) + + def test_render_markdown_empty_string(self): + """render_markdown filter should handle empty strings.""" + from boxes.templatetags.dict_extras import render_markdown + result = render_markdown('') + self.assertEqual(result, '') + + def test_render_markdown_none(self): + """render_markdown filter should handle None.""" + from boxes.templatetags.dict_extras import render_markdown + result = render_markdown(None) + self.assertEqual(result, '') + + def test_render_markdown_tables(self): + """render_markdown filter should convert tables.""" + from boxes.templatetags.dict_extras import render_markdown + md = '| A | B |\n|---|---|\n| 1 | 2 |' + result = render_markdown(md) + self.assertIn('', result) + self.assertIn('
', result) + self.assertIn('', result) + + def test_truncate_markdown_basic(self): + """truncate_markdown filter should truncate and strip Markdown.""" + from boxes.templatetags.dict_extras import truncate_markdown + result = truncate_markdown('**bold** text here', 10) + self.assertNotIn('**', result) + self.assertNotIn('', result) + + def test_truncate_markdown_long_text(self): + """truncate_markdown filter should add ellipsis for long text.""" + from boxes.templatetags.dict_extras import truncate_markdown + long_text = 'This is a very long text that should be truncated' + result = truncate_markdown(long_text, 20) + self.assertTrue(result.endswith('...')) + self.assertLessEqual(len(result), 25) + + def test_truncate_markdown_empty(self): + """truncate_markdown filter should handle empty strings.""" + from boxes.templatetags.dict_extras import truncate_markdown + result = truncate_markdown('') + self.assertEqual(result, '') + + def test_truncate_markdown_short_text(self): + """truncate_markdown filter should not truncate short text.""" + from boxes.templatetags.dict_extras import truncate_markdown + result = truncate_markdown('Short', 100) + self.assertEqual(result, 'Short') + self.assertNotIn('...', result) + + +class MarkdownInViewsTests(AuthTestCase): + """Tests for Markdown rendering in views.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.box_type = BoxType.objects.create( + name='Standard Box', + width=200, + height=100, + length=300 + ) + self.box = Box.objects.create( + id='BOX001', + box_type=self.box_type + ) + + def test_thing_detail_renders_markdown(self): + """Thing detail page should render Markdown in description.""" + thing = Thing.objects.create( + name='Test Item', + box=self.box, + description='**Bold text** and *italic*' + ) + response = self.client.get(f'/thing/{thing.id}/') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Bold text') + self.assertContains(response, 'italic') + + def test_thing_detail_renders_markdown_links(self): + """Thing detail page should render Markdown links with target blank.""" + thing = Thing.objects.create( + name='Test Item', + box=self.box, + description='Check [this link](https://example.com)' + ) + response = self.client.get(f'/thing/{thing.id}/') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'href="https://example.com"') + self.assertContains(response, 'target="_blank"') + + def test_thing_detail_sanitizes_html(self): + """Thing detail page should sanitize dangerous HTML in description.""" + thing = Thing.objects.create( + name='Test Item', + box=self.box, + description='Normal text' + ) + response = self.client.get(f'/thing/{thing.id}/') + self.assertEqual(response.status_code, 200) + # The description should not contain script tags (they are stripped) + # Note: The base template has a