4 Commits

Author SHA1 Message Date
ca50832b54 Markdown support for description fields added; Tests updated
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 1m44s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 6s
2026-01-05 11:00:16 +01:00
5c0b09f78e Tests and AGENTS.md updated 2026-01-05 09:33:39 +01:00
73b39ec189 Merge pull request 'Some bugs (box-management didn't work); Tags now on search and in box content' (#5) from feature/tagging into master
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 6s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 3s
Reviewed-on: #5
2026-01-04 10:12:24 +00:00
8263afb2a5 Merge pull request 'feature/tagging' (#4) from feature/tagging into master
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 3s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 6s
Reviewed-on: #4
2026-01-03 21:28:38 +00:00
7 changed files with 707 additions and 291 deletions

122
AGENTS.md
View File

@@ -214,12 +214,11 @@ labhelper/
│ │ ├── add_things.html # Form to add multiple things
│ │ ├── box_detail.html # Box contents view
│ │ ├── box_management.html # Box/BoxType CRUD management
│ │ ├── index.html # Home page
│ │ ├── index.html # Home page with boxes and tags
│ │ ├── search.html # Search page with AJAX
│ │ ── thing_detail.html # Thing details view
│ │ └── thing_type_detail.html # Thing type hierarchy view
│ │ ── 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
@@ -259,19 +258,24 @@ labhelper/
|-------|-------------|------------|
| **BoxType** | Type of storage box with dimensions | `name`, `width`, `height`, `length` (in mm) |
| **Box** | A storage box in the lab | `id` (CharField PK, max 10), `box_type` (FK) |
| **ThingType** | Hierarchical category (MPTT) | `name`, `parent` (TreeForeignKey to self) |
| **Thing** | An item stored in a box | `name`, `thing_type` (FK), `box` (FK), `description`, `picture` |
| **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` (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` |
**Model Relationships:**
- BoxType -> Box (1:N via `boxes` related_name, PROTECT on delete)
- Box -> Thing (1:N via `things` related_name, PROTECT on delete)
- ThingType -> Thing (1:N via `things` related_name, PROTECT on delete)
- ThingType -> ThingType (self-referential tree via MPTT)
- Facet -> Tag (1:N via `tags` related_name, CASCADE on delete)
- Thing <-> Tag (M2M via `tags` related_name on Thing, `things` related_name on Tag)
- Thing -> ThingFile (1:N via `files` related_name, CASCADE on delete)
- Thing -> ThingLink (1:N via `links` related_name, CASCADE on delete)
**Facet Cardinality:**
- `single`: A thing can have at most one tag from this facet (e.g., Priority: High/Medium/Low)
- `multiple`: A thing can have multiple tags from this facet (e.g., Category: Electronics, Tools)
## Available Django Extensions
The project includes these pre-installed packages:
@@ -371,7 +375,7 @@ The project uses a base template system at `labhelper/templates/base.html`. All
| View Function | URL Pattern | Name | Description |
|---------------|-------------|------|-------------|
| `index` | `/` | `index` | Home page with boxes grid and thing types tree |
| `index` | `/` | `index` | Home page with boxes grid and tags overview |
| `box_management` | `/box-management/` | `box_management` | Manage boxes and box types |
| `add_box_type` | `/box-type/add/` | `add_box_type` | Add new box type |
| `edit_box_type` | `/box-type/<int:type_id>/edit/` | `edit_box_type` | Edit box type |
@@ -380,8 +384,7 @@ The project uses a base template system at `labhelper/templates/base.html`. All
| `edit_box` | `/box/<str:box_id>/edit/` | `edit_box` | Edit box |
| `delete_box` | `/box/<str:box_id>/delete/` | `delete_box` | Delete box |
| `box_detail` | `/box/<str:box_id>/` | `box_detail` | View box contents |
| `thing_detail` | `/thing/<int:thing_id>/` | `thing_detail` | View/edit thing (move, picture, files, links) |
| `thing_type_detail` | `/thing-type/<int:type_id>/` | `thing_type_detail` | View thing type hierarchy |
| `thing_detail` | `/thing/<int:thing_id>/` | `thing_detail` | View/edit thing (move, picture, files, links, tags) |
| `add_things` | `/box/<str:box_id>/add/` | `add_things` | Add multiple things to a box |
| `search` | `/search/` | `search` | Search page |
| `search_api` | `/search/api/` | `search_api` | AJAX search endpoint |
@@ -394,47 +397,92 @@ 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
<a href="{% url 'box_detail' box.id %}">
```
```django
<a href="{% url 'box_detail' box.id %}">
```
5. **Use icons with Font Awesome**
```django
<i class="fas fa-box"></i>
```
```django
<i class="fas fa-box"></i>
```
6. **Add breadcrumbs for navigation**
```django
<p class="breadcrumb">
<a href="/"><i class="fas fa-home"></i> Home</a> /
<a href="/box/{{ box.id }}/"><i class="fas fa-box"></i> Box {{ box.id }}</a>
</p>
```
```django
<p class="breadcrumb">
<a href="/"><i class="fas fa-home"></i> Home</a> /
<a href="/box/{{ box.id }}/"><i class="fas fa-box"></i> Box {{ box.id }}</a>
</p>
```
### 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
<!-- Full Markdown rendering -->
<div class="markdown-content">
{{ thing.description|render_markdown }}
</div>
<!-- Truncated plain text preview -->
{{ 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 (`<script>`, `<iframe>`, etc.) are stripped
- Only safe HTML tags and attributes are allowed
- External links automatically get `target="_blank"` and security attributes
## Forms
| Form | Model | Purpose |
|------|-------|---------|
| `ThingForm` | Thing | Add/edit a thing (name, type, description, picture) |
| `ThingForm` | Thing | Add/edit a thing (name, description, picture, tags) |
| `ThingPictureForm` | Thing | Upload/change thing picture only |
| `ThingFileForm` | ThingFile | Add file attachment |
| `ThingLinkForm` | ThingLink | Add link |

View File

@@ -27,7 +27,7 @@ spec:
mountPath: /data
containers:
- name: web
image: git.baumann.gr/adebaumann/labhelper:0.049
image: git.baumann.gr/adebaumann/labhelper:0.050
imagePullPolicy: Always
ports:
- containerPort: 8000

View File

@@ -1,5 +1,6 @@
{% extends "base.html" %}
{% load thumbnail %}
{% load dict_extras %}
{% block title %}Box {{ box.id }} - LabHelper{% endblock %}
@@ -65,7 +66,7 @@
<span style="color: #999; font-style: italic; font-size: 13px;">-</span>
{% endif %}
</td>
<td style="padding: 15px 20px; color: #777;">{{ thing.description|default:"-" }}</td>
<td style="padding: 15px 20px; color: #777;">{{ thing.description|truncate_markdown:100|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -1,5 +1,6 @@
{% extends "base.html" %}
{% load thumbnail %}
{% load dict_extras %}
{% block title %}{{ thing.name }} - LabHelper{% endblock %}
@@ -95,8 +96,8 @@
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
<i class="fas fa-align-left"></i> Description
</div>
<div style="font-size: 16px; color: #555; line-height: 1.6; white-space: pre-wrap;">
{% spaceless %}{{ thing.description }}{% endspaceless %}
<div class="markdown-content" style="font-size: 16px; color: #555; line-height: 1.6;">
{{ thing.description|render_markdown }}
</div>
</div>
{% endif %}
@@ -273,6 +274,85 @@
{% endblock %}
{% block extra_css %}
<style>
.markdown-content p {
margin: 0 0 1em 0;
}
.markdown-content p:last-child {
margin-bottom: 0;
}
.markdown-content h1, .markdown-content h2, .markdown-content h3,
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
margin: 1.5em 0 0.5em 0;
color: #333;
font-weight: 600;
}
.markdown-content h1:first-child, .markdown-content h2:first-child,
.markdown-content h3:first-child {
margin-top: 0;
}
.markdown-content ul, .markdown-content ol {
margin: 0.5em 0;
padding-left: 2em;
}
.markdown-content li {
margin: 0.25em 0;
}
.markdown-content code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
}
.markdown-content pre {
background: #f4f4f4;
padding: 15px;
border-radius: 8px;
overflow-x: auto;
margin: 1em 0;
}
.markdown-content pre code {
background: none;
padding: 0;
}
.markdown-content blockquote {
border-left: 4px solid #667eea;
margin: 1em 0;
padding: 0.5em 1em;
background: #f8f9fa;
color: #666;
}
.markdown-content a {
color: #667eea;
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
.markdown-content th, .markdown-content td {
border: 1px solid #e0e0e0;
padding: 8px 12px;
text-align: left;
}
.markdown-content th {
background: #f8f9fa;
font-weight: 600;
}
.markdown-content hr {
border: none;
border-top: 2px solid #e0e0e0;
margin: 1.5em 0;
}
</style>
{% endblock %}
{% block extra_js %}
<script>
$('#new_box').on('focus', function() {

View File

@@ -1,7 +1,95 @@
import bleach
import markdown
from django import template
from django.utils.safestring import mark_safe
register = template.Library()
@register.filter
def get_item(dictionary, key):
return dictionary.get(key)
return dictionary.get(key)
@register.filter
def render_markdown(text):
"""
Convert Markdown text to sanitized HTML.
Uses bleach to sanitize HTML output and prevent XSS attacks.
Allows common formatting tags: bold, italic, links, lists, code, etc.
"""
if not text:
return ''
# Convert Markdown to HTML
html = markdown.markdown(
text,
extensions=[
'markdown.extensions.fenced_code',
'markdown.extensions.tables',
'markdown.extensions.nl2br',
]
)
# Allowed HTML tags and attributes for sanitization
allowed_tags = [
'p', 'br', 'strong', 'em', 'b', 'i', 'u',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li',
'a', 'code', 'pre',
'blockquote', 'hr',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
]
allowed_attrs = {
'a': ['href', 'title', 'target'],
'th': ['align'],
'td': ['align'],
}
# Sanitize HTML
clean_html = bleach.clean(
html,
tags=allowed_tags,
attributes=allowed_attrs,
strip=True
)
# Add target="_blank" and rel="noopener" to external links
clean_html = bleach.linkify(
clean_html,
callbacks=[_add_target_blank],
skip_tags=['pre', 'code']
)
return mark_safe(clean_html)
def _add_target_blank(attrs, new=False):
"""Add target="_blank" and rel="noopener noreferrer" to links."""
attrs[(None, 'target')] = '_blank'
attrs[(None, 'rel')] = 'noopener noreferrer'
return attrs
@register.filter
def truncate_markdown(text, length=100):
"""
Convert Markdown to plain text and truncate.
Useful for showing a preview of Markdown content in listings.
"""
if not text:
return ''
# Convert Markdown to HTML, then strip tags
html = markdown.markdown(text)
plain_text = bleach.clean(html, tags=[], strip=True)
# Normalize whitespace
plain_text = ' '.join(plain_text.split())
# Truncate
if len(plain_text) > length:
return plain_text[:length].rsplit(' ', 1)[0] + '...'
return plain_text

View File

@@ -5,8 +5,8 @@ from django.db import IntegrityError
from django.test import Client, TestCase
from django.urls import reverse
from .admin import BoxAdmin, BoxTypeAdmin, ThingAdmin, ThingTypeAdmin
from .models import Box, BoxType, Thing, ThingFile, ThingLink, ThingType
from .admin import BoxAdmin, BoxTypeAdmin, ThingAdmin
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
class AuthTestCase(TestCase):
@@ -146,75 +146,6 @@ class BoxAdminTests(TestCase):
self.assertEqual(self.admin.search_fields, ('id',))
class ThingTypeModelTests(TestCase):
"""Tests for the ThingType model."""
def setUp(self):
"""Set up test fixtures."""
self.thing_type = ThingType.objects.create(name='Electronics')
def test_thing_type_str_returns_name(self):
"""ThingType __str__ should return the name."""
self.assertEqual(str(self.thing_type), 'Electronics')
def test_thing_type_creation(self):
"""ThingType should be created with correct attributes."""
self.assertEqual(self.thing_type.name, 'Electronics')
def test_thing_type_hierarchy(self):
"""ThingType should support parent-child relationships."""
child = ThingType.objects.create(
name='Resistors',
parent=self.thing_type
)
self.assertEqual(child.parent, self.thing_type)
self.assertIn(child, self.thing_type.children.all())
def test_thing_type_is_leaf_node(self):
"""ThingType without children should be a leaf node."""
self.assertTrue(self.thing_type.is_leaf_node())
def test_thing_type_is_not_leaf_with_children(self):
"""ThingType with children should not be a leaf node."""
ThingType.objects.create(name='Capacitors', parent=self.thing_type)
self.assertFalse(self.thing_type.is_leaf_node())
def test_thing_type_ancestors(self):
"""ThingType should return correct ancestors."""
child = ThingType.objects.create(
name='Resistors',
parent=self.thing_type
)
grandchild = ThingType.objects.create(
name='10k Resistors',
parent=child
)
ancestors = list(grandchild.get_ancestors())
self.assertEqual(ancestors, [self.thing_type, child])
def test_thing_type_descendants(self):
"""ThingType should return correct descendants."""
child = ThingType.objects.create(
name='Resistors',
parent=self.thing_type
)
grandchild = ThingType.objects.create(
name='10k Resistors',
parent=child
)
descendants = list(self.thing_type.get_descendants())
self.assertEqual(descendants, [child, grandchild])
def test_thing_type_level(self):
"""ThingType should have correct level in hierarchy."""
self.assertEqual(self.thing_type.level, 0)
child = ThingType.objects.create(
name='Resistors',
parent=self.thing_type
)
self.assertEqual(child.level, 1)
class ThingModelTests(TestCase):
"""Tests for the Thing model."""
@@ -230,10 +161,8 @@ class ThingModelTests(TestCase):
id='BOX001',
box_type=self.box_type
)
self.thing_type = ThingType.objects.create(name='Electronics')
self.thing = Thing.objects.create(
name='Arduino Uno',
thing_type=self.thing_type,
box=self.box
)
@@ -244,7 +173,6 @@ class ThingModelTests(TestCase):
def test_thing_creation(self):
"""Thing should be created with correct attributes."""
self.assertEqual(self.thing.name, 'Arduino Uno')
self.assertEqual(self.thing.thing_type, self.thing_type)
self.assertEqual(self.thing.box, self.box)
def test_thing_optional_description(self):
@@ -275,7 +203,6 @@ class ThingModelTests(TestCase):
)
thing = Thing.objects.create(
name='Test Item',
thing_type=self.thing_type,
box=self.box,
picture=image
)
@@ -287,49 +214,25 @@ class ThingModelTests(TestCase):
"""Things should be ordered by name."""
Thing.objects.create(
name='Zeta Item',
thing_type=self.thing_type,
box=self.box
)
Thing.objects.create(
name='Alpha Item',
thing_type=self.thing_type,
box=self.box
)
things = list(Thing.objects.values_list('name', flat=True))
self.assertEqual(things, ['Alpha Item', 'Arduino Uno', 'Zeta Item'])
def test_thing_type_relationship(self):
"""Thing should be accessible from ThingType via related_name."""
self.assertIn(self.thing, self.thing_type.things.all())
def test_thing_box_relationship(self):
"""Thing should be accessible from Box via related_name."""
self.assertIn(self.thing, self.box.things.all())
def test_thing_type_protect_on_delete(self):
"""Deleting a ThingType with things should raise IntegrityError."""
with self.assertRaises(IntegrityError):
self.thing_type.delete()
def test_box_protect_on_delete_with_things(self):
"""Deleting a Box with things should raise IntegrityError."""
with self.assertRaises(IntegrityError):
self.box.delete()
class ThingTypeAdminTests(TestCase):
"""Tests for the ThingType admin configuration."""
def setUp(self):
"""Set up test fixtures."""
self.site = AdminSite()
self.admin = ThingTypeAdmin(ThingType, self.site)
def test_search_fields(self):
"""ThingTypeAdmin should search by name."""
self.assertEqual(self.admin.search_fields, ('name',))
class ThingAdminTests(TestCase):
"""Tests for the Thing admin configuration."""
@@ -342,13 +245,9 @@ class ThingAdminTests(TestCase):
"""ThingAdmin should display correct fields."""
self.assertEqual(
self.admin.list_display,
('name', 'thing_type', 'box')
('name', 'box')
)
def test_list_filter(self):
"""ThingAdmin should filter by thing_type and box."""
self.assertEqual(self.admin.list_filter, ('thing_type', 'box'))
def test_search_fields(self):
"""ThingAdmin should search by name and description."""
self.assertEqual(self.admin.search_fields, ('name', 'description'))
@@ -395,7 +294,6 @@ class BoxDetailViewTests(AuthTestCase):
id='BOX001',
box_type=self.box_type
)
self.thing_type = ThingType.objects.create(name='Electronics')
def test_box_detail_returns_200(self):
"""Box detail page should return 200 status."""
@@ -433,20 +331,17 @@ class BoxDetailViewTests(AuthTestCase):
"""Box detail page should show things in the box."""
Thing.objects.create(
name='Arduino Uno',
thing_type=self.thing_type,
box=self.box,
description='A microcontroller board'
)
response = self.client.get(f'/box/{self.box.id}/')
self.assertContains(response, 'Arduino Uno')
self.assertContains(response, 'Electronics')
self.assertContains(response, 'A microcontroller board')
def test_box_detail_shows_no_image_placeholder(self):
"""Box detail page should show placeholder for things without images."""
Thing.objects.create(
name='Test Item',
thing_type=self.thing_type,
box=self.box
)
response = self.client.get(f'/box/{self.box.id}/')
@@ -456,12 +351,10 @@ class BoxDetailViewTests(AuthTestCase):
"""Box detail page should show multiple things."""
Thing.objects.create(
name='Item One',
thing_type=self.thing_type,
box=self.box
)
Thing.objects.create(
name='Item Two',
thing_type=self.thing_type,
box=self.box
)
response = self.client.get(f'/box/{self.box.id}/')
@@ -489,7 +382,6 @@ class BoxDetailViewTests(AuthTestCase):
)
thing = Thing.objects.create(
name='Item With Image',
thing_type=self.thing_type,
box=self.box,
picture=image
)
@@ -527,10 +419,8 @@ class ThingDetailViewTests(AuthTestCase):
id='BOX001',
box_type=self.box_type
)
self.thing_type = ThingType.objects.create(name='Electronics')
self.thing = Thing.objects.create(
name='Arduino Uno',
thing_type=self.thing_type,
box=self.box,
description='A microcontroller board'
)
@@ -550,10 +440,10 @@ class ThingDetailViewTests(AuthTestCase):
response = self.client.get(f'/thing/{self.thing.id}/')
self.assertContains(response, 'Arduino Uno')
def test_thing_detail_shows_type(self):
"""Thing detail page should show thing type."""
def test_thing_detail_shows_tags_section(self):
"""Thing detail page should show tags section."""
response = self.client.get(f'/thing/{self.thing.id}/')
self.assertContains(response, 'Electronics')
self.assertContains(response, 'Tags')
def test_thing_detail_shows_description(self):
"""Thing detail page should show thing description."""
@@ -644,16 +534,13 @@ class SearchApiTests(AuthTestCase):
id='BOX001',
box_type=self.box_type
)
self.thing_type = ThingType.objects.create(name='Electronics')
Thing.objects.create(
name='Arduino Uno',
thing_type=self.thing_type,
box=self.box,
description='A microcontroller board'
)
Thing.objects.create(
name='Raspberry Pi',
thing_type=self.thing_type,
box=self.box
)
@@ -683,7 +570,6 @@ class SearchApiTests(AuthTestCase):
"""Search API should truncate long descriptions."""
Thing.objects.create(
name='Long Description Item',
thing_type=self.thing_type,
box=self.box,
description='A' * 200
)
@@ -691,14 +577,14 @@ class SearchApiTests(AuthTestCase):
self.assertEqual(response.status_code, 200)
results = response.json()['results']
self.assertEqual(len(results), 1)
self.assertLessEqual(len(results[0]['description']), 100)
# Truncated text + '...' should be around 100 chars
self.assertLessEqual(len(results[0]['description']), 105)
def test_search_api_limits_results(self):
"""Search API should limit results to 50."""
for i in range(60):
Thing.objects.create(
name=f'Item {i}',
thing_type=self.thing_type,
box=self.box
)
response = self.client.get('/search/api/?q=Item')
@@ -737,7 +623,6 @@ class AddThingsViewTests(AuthTestCase):
id='BOX001',
box_type=self.box_type
)
self.thing_type = ThingType.objects.create(name='Electronics')
def test_add_things_get_request(self):
"""Add things page should return 200 for GET request."""
@@ -765,13 +650,10 @@ class AddThingsViewTests(AuthTestCase):
'form-TOTAL_FORMS': '3',
'form-INITIAL_FORMS': '0',
'form-0-name': 'Arduino Uno',
'form-0-thing_type': self.thing_type.id,
'form-0-description': 'A microcontroller',
'form-1-name': 'LED Strip',
'form-1-thing_type': self.thing_type.id,
'form-1-description': 'Lighting component',
'form-2-name': '',
'form-2-thing_type': '',
'form-2-description': '',
})
self.assertEqual(response.status_code, 200)
@@ -782,42 +664,23 @@ class AddThingsViewTests(AuthTestCase):
"""Adding things without name should show error."""
response = self.client.post(f'/box/{self.box.id}/add/', {
'form-TOTAL_FORMS': '2',
'form-0-thing_type': self.thing_type.id,
'form-1-thing_type': self.thing_type.id,
})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'This field is required')
def test_add_things_post_partial_valid_invalid(self):
"""Partial submission: one valid, one missing name - nothing saved due to formset validation."""
def test_add_things_post_partial_valid_and_empty(self):
"""Partial submission: one valid, one empty - only valid ones are saved."""
response = self.client.post(f'/box/{self.box.id}/add/', {
'form-TOTAL_FORMS': '2',
'form-INITIAL_FORMS': '0',
'form-0-name': 'Arduino Uno',
'form-0-thing_type': self.thing_type.id,
'form-1-name': '',
'form-1-thing_type': self.thing_type.id,
})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'This field is required')
# Formset validation fails, so nothing is saved
self.assertEqual(Thing.objects.count(), 0)
def test_add_things_creates_thing_types(self):
"""Can add things with different thing types."""
new_type = ThingType.objects.create(name='Components')
response = self.client.post(f'/box/{self.box.id}/add/', {
'form-TOTAL_FORMS': '2',
'form-INITIAL_FORMS': '0',
'form-0-name': 'Resistor',
'form-0-thing_type': new_type.id,
'form-1-name': 'Capacitor',
'form-1-thing_type': new_type.id,
})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Added 2 things successfully')
self.assertEqual(Thing.objects.count(), 2)
self.assertEqual(Thing.objects.filter(thing_type=new_type).count(), 2)
# Valid row is saved, empty row is skipped
self.assertContains(response, 'Added 1 thing successfully')
self.assertEqual(Thing.objects.count(), 1)
self.assertEqual(Thing.objects.first().name, 'Arduino Uno')
def test_add_things_empty_all(self):
"""Submitting empty forms should not create anything."""
@@ -825,9 +688,7 @@ class AddThingsViewTests(AuthTestCase):
'form-TOTAL_FORMS': '2',
'form-INITIAL_FORMS': '0',
'form-0-name': '',
'form-0-thing_type': '',
'form-1-name': '',
'form-1-thing_type': '',
})
self.assertEqual(response.status_code, 200)
self.assertEqual(Thing.objects.count(), 0)
@@ -839,16 +700,12 @@ class AddThingsViewTests(AuthTestCase):
def test_add_things_populates_box(self):
"""Created things should be assigned to the correct box."""
self.thing_type_2 = ThingType.objects.create(name='Mechanical')
response = self.client.post(f'/box/{self.box.id}/add/', {
'form-TOTAL_FORMS': '3',
'form-INITIAL_FORMS': '0',
'form-0-name': 'Bolt',
'form-0-thing_type': self.thing_type_2.id,
'form-1-name': 'Nut',
'form-1-thing_type': self.thing_type_2.id,
'form-2-name': 'Washer',
'form-2-thing_type': self.thing_type_2.id,
})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Added 3 things successfully')
@@ -864,75 +721,6 @@ class AddThingsViewTests(AuthTestCase):
self.assertRedirects(response, f'/login/?next=/box/{self.box.id}/add/')
class ThingTypeDetailViewTests(AuthTestCase):
"""Tests for thing type detail view."""
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
)
self.parent_type = ThingType.objects.create(name='Electronics')
self.child_type = ThingType.objects.create(
name='Microcontrollers',
parent=self.parent_type
)
self.thing = Thing.objects.create(
name='Arduino Uno',
thing_type=self.child_type,
box=self.box
)
def test_thing_type_detail_returns_200(self):
"""Thing type detail page should return 200 status."""
response = self.client.get(f'/thing-type/{self.parent_type.id}/')
self.assertEqual(response.status_code, 200)
def test_thing_type_detail_returns_404_for_invalid(self):
"""Thing type detail page should return 404 for non-existent type."""
response = self.client.get('/thing-type/99999/')
self.assertEqual(response.status_code, 404)
def test_thing_type_detail_shows_type_name(self):
"""Thing type detail page should show type name."""
response = self.client.get(f'/thing-type/{self.parent_type.id}/')
self.assertContains(response, 'Electronics')
def test_thing_type_detail_shows_descendants(self):
"""Thing type detail page should show descendant types."""
response = self.client.get(f'/thing-type/{self.parent_type.id}/')
self.assertContains(response, 'Microcontrollers')
def test_thing_type_detail_shows_things(self):
"""Thing type detail page should show things of this type."""
response = self.client.get(f'/thing-type/{self.parent_type.id}/')
self.assertContains(response, 'Arduino Uno')
def test_thing_type_detail_uses_correct_template(self):
"""Thing type detail page should use correct template."""
response = self.client.get(f'/thing-type/{self.parent_type.id}/')
self.assertTemplateUsed(response, 'boxes/thing_type_detail.html')
def test_thing_type_detail_url_name(self):
"""Thing type detail URL should be reversible by name."""
url = reverse('thing_type_detail', kwargs={'type_id': 1})
self.assertEqual(url, '/thing-type/1/')
def test_thing_type_detail_requires_login(self):
"""Thing type detail page should redirect to login if not authenticated."""
self.client.logout()
response = self.client.get(f'/thing-type/{self.parent_type.id}/')
self.assertRedirects(response, f'/login/?next=/thing-type/{self.parent_type.id}/')
class LoginViewTests(TestCase):
"""Tests for login view."""
@@ -1198,10 +986,8 @@ class BoxCRUDTests(AuthTestCase):
def test_delete_box_with_things_redirects(self):
"""Deleting a box with things should redirect without deleting."""
thing_type = ThingType.objects.create(name='Test')
Thing.objects.create(
name='Test Item',
thing_type=thing_type,
box=self.box
)
box_id = self.box.id
@@ -1227,10 +1013,8 @@ class ThingPictureUploadTests(AuthTestCase):
id='BOX001',
box_type=self.box_type
)
self.thing_type = ThingType.objects.create(name='Electronics')
self.thing = Thing.objects.create(
name='Arduino Uno',
thing_type=self.thing_type,
box=self.box
)
self.image_data = (
@@ -1302,15 +1086,6 @@ class ThingPictureUploadTests(AuthTestCase):
self.thing.refresh_from_db()
self.assertFalse(self.thing.picture.name)
def test_delete_picture_on_thing_without_picture(self):
"""Deleting a picture from thing without picture should succeed."""
response = self.client.post(f'/thing/{self.thing.id}/', {
'action': 'delete_picture'
})
self.assertRedirects(response, f'/thing/{self.thing.id}/')
self.thing.refresh_from_db()
self.assertFalse(self.thing.picture.name)
class ThingFileModelTests(AuthTestCase):
"""Tests for the ThingFile model."""
@@ -1328,10 +1103,8 @@ class ThingFileModelTests(AuthTestCase):
id='BOX001',
box_type=self.box_type
)
self.thing_type = ThingType.objects.create(name='Electronics')
self.thing = Thing.objects.create(
name='Arduino Uno',
thing_type=self.thing_type,
box=self.box
)
@@ -1400,10 +1173,8 @@ class ThingLinkModelTests(AuthTestCase):
id='BOX001',
box_type=self.box_type
)
self.thing_type = ThingType.objects.create(name='Electronics')
self.thing = Thing.objects.create(
name='Arduino Uno',
thing_type=self.thing_type,
box=self.box
)
@@ -1472,10 +1243,8 @@ class ThingFileAndLinkCRUDTests(AuthTestCase):
id='BOX001',
box_type=self.box_type
)
self.thing_type = ThingType.objects.create(name='Electronics')
self.thing = Thing.objects.create(
name='Arduino Uno',
thing_type=self.thing_type,
box=self.box
)
@@ -1544,7 +1313,6 @@ class ThingFileAndLinkCRUDTests(AuthTestCase):
"""Cannot delete file from another thing."""
other_thing = Thing.objects.create(
name='Other Item',
thing_type=self.thing_type,
box=self.box
)
thing_file = ThingFile.objects.create(
@@ -1564,7 +1332,6 @@ class ThingFileAndLinkCRUDTests(AuthTestCase):
"""Cannot delete link from another thing."""
other_thing = Thing.objects.create(
name='Other Item',
thing_type=self.thing_type,
box=self.box
)
thing_link = ThingLink.objects.create(
@@ -1700,3 +1467,420 @@ class ThingFileAndLinkCRUDTests(AuthTestCase):
self.assertEqual(response.status_code, 200)
results = response.json()['results']
self.assertGreater(len(results), 0)
class FacetModelTests(TestCase):
"""Tests for the Facet model."""
def setUp(self):
"""Set up test fixtures."""
self.facet = Facet.objects.create(
name='Category',
color='#667eea',
cardinality=Facet.Cardinality.MULTIPLE
)
def test_facet_str_returns_name(self):
"""Facet __str__ should return the name."""
self.assertEqual(str(self.facet), 'Category')
def test_facet_creation(self):
"""Facet should be created with correct attributes."""
self.assertEqual(self.facet.name, 'Category')
self.assertEqual(self.facet.color, '#667eea')
self.assertEqual(self.facet.cardinality, Facet.Cardinality.MULTIPLE)
def test_facet_auto_slug(self):
"""Facet should auto-generate slug from name."""
facet = Facet.objects.create(name='Test Facet')
self.assertEqual(facet.slug, 'test-facet')
def test_facet_ordering(self):
"""Facets should be ordered by name."""
Facet.objects.create(name='Alpha Facet')
Facet.objects.create(name='Zeta Facet')
facets = list(Facet.objects.values_list('name', flat=True))
self.assertEqual(facets, ['Alpha Facet', 'Category', 'Zeta Facet'])
def test_facet_single_cardinality(self):
"""Facet can have single cardinality."""
facet = Facet.objects.create(
name='Priority',
cardinality=Facet.Cardinality.SINGLE
)
self.assertEqual(facet.cardinality, Facet.Cardinality.SINGLE)
def test_facet_default_color(self):
"""Facet should have default color."""
facet = Facet.objects.create(name='No Color')
self.assertEqual(facet.color, '#667eea')
def test_facet_name_unique(self):
"""Facet name should be unique."""
from django.db import IntegrityError
with self.assertRaises(IntegrityError):
Facet.objects.create(name='Category')
class TagModelTests(TestCase):
"""Tests for the Tag model."""
def setUp(self):
"""Set up test fixtures."""
self.facet = Facet.objects.create(
name='Category',
color='#667eea'
)
self.tag = Tag.objects.create(
facet=self.facet,
name='Electronics'
)
def test_tag_str_returns_facet_and_name(self):
"""Tag __str__ should return facet:name format."""
self.assertEqual(str(self.tag), 'Category:Electronics')
def test_tag_creation(self):
"""Tag should be created with correct attributes."""
self.assertEqual(self.tag.facet, self.facet)
self.assertEqual(self.tag.name, 'Electronics')
def test_tag_ordering(self):
"""Tags should be ordered by facet then name."""
Tag.objects.create(facet=self.facet, name='Alpha')
Tag.objects.create(facet=self.facet, name='Zeta')
tags = list(Tag.objects.values_list('name', flat=True))
self.assertEqual(tags, ['Alpha', 'Electronics', 'Zeta'])
def test_tag_facet_relationship(self):
"""Tag should be accessible from Facet via related_name."""
self.assertIn(self.tag, self.facet.tags.all())
def test_tag_cascade_on_facet_delete(self):
"""Deleting a Facet should delete its tags."""
tag_id = self.tag.id
self.facet.delete()
self.assertFalse(Tag.objects.filter(id=tag_id).exists())
def test_tag_unique_within_facet(self):
"""Tag name should be unique within a facet."""
from django.db import IntegrityError
with self.assertRaises(IntegrityError):
Tag.objects.create(facet=self.facet, name='Electronics')
def test_same_tag_name_different_facet(self):
"""Same tag name can exist in different facets."""
other_facet = Facet.objects.create(name='Other')
tag = Tag.objects.create(facet=other_facet, name='Electronics')
self.assertEqual(tag.name, 'Electronics')
class ThingTagTests(AuthTestCase):
"""Tests for Thing-Tag relationships."""
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
)
self.thing = Thing.objects.create(
name='Arduino Uno',
box=self.box
)
self.facet = Facet.objects.create(
name='Category',
color='#667eea',
cardinality=Facet.Cardinality.MULTIPLE
)
self.tag = Tag.objects.create(
facet=self.facet,
name='Electronics'
)
def test_thing_can_have_tags(self):
"""Thing can have tags."""
self.thing.tags.add(self.tag)
self.assertIn(self.tag, self.thing.tags.all())
def test_thing_can_have_multiple_tags(self):
"""Thing can have multiple tags."""
tag2 = Tag.objects.create(facet=self.facet, name='Microcontroller')
self.thing.tags.add(self.tag, tag2)
self.assertEqual(self.thing.tags.count(), 2)
def test_tag_can_have_multiple_things(self):
"""Tag can be assigned to multiple things."""
thing2 = Thing.objects.create(name='Raspberry Pi', box=self.box)
self.thing.tags.add(self.tag)
thing2.tags.add(self.tag)
self.assertEqual(self.tag.things.count(), 2)
def test_removing_tag_from_thing(self):
"""Tag can be removed from thing."""
self.thing.tags.add(self.tag)
self.thing.tags.remove(self.tag)
self.assertNotIn(self.tag, self.thing.tags.all())
def test_thing_detail_add_tag(self):
"""Thing detail page can add a tag via POST."""
response = self.client.post(
f'/thing/{self.thing.id}/',
{'action': 'add_tag', 'tag_id': str(self.tag.id)}
)
self.assertRedirects(response, f'/thing/{self.thing.id}/')
self.thing.refresh_from_db()
self.assertIn(self.tag, self.thing.tags.all())
def test_thing_detail_remove_tag(self):
"""Thing detail page can remove a tag via POST."""
self.thing.tags.add(self.tag)
response = self.client.post(
f'/thing/{self.thing.id}/',
{'action': 'remove_tag', 'tag_id': str(self.tag.id)}
)
self.assertRedirects(response, f'/thing/{self.thing.id}/')
self.thing.refresh_from_db()
self.assertNotIn(self.tag, self.thing.tags.all())
def test_thing_detail_shows_available_tags(self):
"""Thing detail page should show available tags to add."""
response = self.client.get(f'/thing/{self.thing.id}/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Electronics')
def test_thing_detail_shows_current_tags(self):
"""Thing detail page should show thing's current tags."""
self.thing.tags.add(self.tag)
response = self.client.get(f'/thing/{self.thing.id}/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Electronics')
def test_single_cardinality_facet_replaces_tag(self):
"""Adding tag from single-cardinality facet replaces existing tag."""
single_facet = Facet.objects.create(
name='Priority',
cardinality=Facet.Cardinality.SINGLE
)
tag_low = Tag.objects.create(facet=single_facet, name='Low')
tag_high = Tag.objects.create(facet=single_facet, name='High')
self.thing.tags.add(tag_low)
response = self.client.post(
f'/thing/{self.thing.id}/',
{'action': 'add_tag', 'tag_id': str(tag_high.id)}
)
self.assertRedirects(response, f'/thing/{self.thing.id}/')
self.thing.refresh_from_db()
self.assertNotIn(tag_low, self.thing.tags.all())
self.assertIn(tag_high, self.thing.tags.all())
def test_search_api_includes_tags(self):
"""Search API results should include tags."""
self.thing.tags.add(self.tag)
response = self.client.get('/search/api/?q=ard')
self.assertEqual(response.status_code, 200)
results = response.json()['results']
self.assertEqual(len(results), 1)
self.assertEqual(len(results[0]['tags']), 1)
self.assertEqual(results[0]['tags'][0]['name'], 'Electronics')
def test_search_api_searches_by_tag_name(self):
"""Search API should find things by tag name."""
self.thing.tags.add(self.tag)
response = self.client.get('/search/api/?q=Electronics')
self.assertEqual(response.status_code, 200)
results = response.json()['results']
self.assertEqual(len(results), 1)
self.assertEqual(results[0]['name'], 'Arduino Uno')
def test_search_api_facet_colon_tag_format(self):
"""Search API should support Facet:Tag search format."""
self.thing.tags.add(self.tag)
response = self.client.get('/search/api/?q=Category:Electronics')
self.assertEqual(response.status_code, 200)
results = response.json()['results']
self.assertEqual(len(results), 1)
self.assertEqual(results[0]['name'], 'Arduino Uno')
class MarkdownRenderingTests(TestCase):
"""Tests for Markdown rendering in templates."""
def test_render_markdown_basic(self):
"""render_markdown filter should convert basic Markdown to HTML."""
from boxes.templatetags.dict_extras import render_markdown
result = render_markdown('**bold** and *italic*')
self.assertIn('<strong>bold</strong>', result)
self.assertIn('<em>italic</em>', result)
def test_render_markdown_links(self):
"""render_markdown filter should convert links."""
from boxes.templatetags.dict_extras import render_markdown
result = render_markdown('[Example](https://example.com)')
self.assertIn('href="https://example.com"', result)
self.assertIn('target="_blank"', result)
self.assertIn('rel="noopener noreferrer"', result)
def test_render_markdown_code(self):
"""render_markdown filter should convert code blocks."""
from boxes.templatetags.dict_extras import render_markdown
result = render_markdown('`inline code`')
self.assertIn('<code>inline code</code>', result)
def test_render_markdown_lists(self):
"""render_markdown filter should convert lists."""
from boxes.templatetags.dict_extras import render_markdown
result = render_markdown('- item 1\n- item 2')
self.assertIn('<ul>', result)
self.assertIn('<li>item 1</li>', result)
def test_render_markdown_sanitizes_script(self):
"""render_markdown filter should sanitize script tags."""
from boxes.templatetags.dict_extras import render_markdown
result = render_markdown('<script>alert("xss")</script>')
self.assertNotIn('<script>', result)
self.assertNotIn('</script>', 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('<table>', result)
self.assertIn('<th>', result)
self.assertIn('<td>', 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('<strong>', 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, '<strong>Bold text</strong>')
self.assertContains(response, '<em>italic</em>')
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='<script>alert("xss")</script>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 <script> tag for jQuery, so we check
# specifically that the malicious script content is not executed
self.assertContains(response, 'Normal text')
# Check the markdown-content div doesn't have script tags
self.assertNotContains(response, '<script>alert')
def test_box_detail_truncates_markdown(self):
"""Box detail page should show truncated plain text description."""
thing = Thing.objects.create(
name='Test Item',
box=self.box,
description='**Bold** and a very long description that should be truncated in the box detail view'
)
response = self.client.get(f'/box/{self.box.id}/')
self.assertEqual(response.status_code, 200)
# Should not contain raw Markdown syntax
self.assertNotContains(response, '**Bold**')
# Should contain the plain text (truncated)
self.assertContains(response, 'Bold')
def test_search_api_strips_markdown(self):
"""Search API should return plain text description."""
thing = Thing.objects.create(
name='Searchable Item',
box=self.box,
description='**Bold text** in description'
)
response = self.client.get('/search/api/?q=Searchable')
self.assertEqual(response.status_code, 200)
results = response.json()['results']
self.assertEqual(len(results), 1)
# Description should be plain text without Markdown
self.assertNotIn('**', results[0]['description'])
self.assertIn('Bold text', results[0]['description'])

View File

@@ -1,3 +1,6 @@
import bleach
import markdown
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.http import HttpResponse, JsonResponse
@@ -14,6 +17,18 @@ from .forms import (
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
def _strip_markdown(text, max_length=100):
"""Convert Markdown to plain text and truncate."""
if not text:
return ''
html = markdown.markdown(text)
plain_text = bleach.clean(html, tags=[], strip=True)
plain_text = ' '.join(plain_text.split())
if len(plain_text) > max_length:
return plain_text[:max_length].rsplit(' ', 1)[0] + '...'
return plain_text
@login_required
def index(request):
"""Home page with boxes and tags."""
@@ -198,7 +213,7 @@ def search_api(request):
'id': thing.id,
'name': thing.name,
'box': thing.box.id,
'description': thing.description[:100] if thing.description else '',
'description': _strip_markdown(thing.description),
'tags': [
{
'name': tag.name,
@@ -240,7 +255,7 @@ def add_things(request, box_id):
things = formset.save(commit=False)
created_count = 0
for thing in things:
if thing.name or thing.thing_type or thing.description or thing.picture:
if thing.name or thing.description or thing.picture:
thing.box = box
thing.save()
created_count += 1