Compare commits
2 Commits
73b39ec189
...
ca50832b54
| Author | SHA1 | Date | |
|---|---|---|---|
| ca50832b54 | |||
| 5c0b09f78e |
122
AGENTS.md
122
AGENTS.md
@@ -214,12 +214,11 @@ labhelper/
|
|||||||
│ │ ├── add_things.html # Form to add multiple things
|
│ │ ├── add_things.html # Form to add multiple things
|
||||||
│ │ ├── box_detail.html # Box contents view
|
│ │ ├── box_detail.html # Box contents view
|
||||||
│ │ ├── box_management.html # Box/BoxType CRUD management
|
│ │ ├── 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
|
│ │ ├── search.html # Search page with AJAX
|
||||||
│ │ ├── thing_detail.html # Thing details view
|
│ │ └── thing_detail.html # Thing details view with tags
|
||||||
│ │ └── thing_type_detail.html # Thing type hierarchy view
|
|
||||||
│ ├── templatetags/
|
│ ├── 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
|
│ ├── admin.py # Admin configuration
|
||||||
│ ├── apps.py # App configuration
|
│ ├── apps.py # App configuration
|
||||||
│ ├── forms.py # All forms and formsets
|
│ ├── forms.py # All forms and formsets
|
||||||
@@ -259,19 +258,24 @@ labhelper/
|
|||||||
|-------|-------------|------------|
|
|-------|-------------|------------|
|
||||||
| **BoxType** | Type of storage box with dimensions | `name`, `width`, `height`, `length` (in mm) |
|
| **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) |
|
| **Box** | A storage box in the lab | `id` (CharField PK, max 10), `box_type` (FK) |
|
||||||
| **ThingType** | Hierarchical category (MPTT) | `name`, `parent` (TreeForeignKey to self) |
|
| **Facet** | A category of tags (e.g., Priority, Category) | `name`, `slug`, `color`, `cardinality` (single/multiple) |
|
||||||
| **Thing** | An item stored in a box | `name`, `thing_type` (FK), `box` (FK), `description`, `picture` |
|
| **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` |
|
| **ThingFile** | File attachment for a Thing | `thing` (FK), `file`, `title`, `uploaded_at` |
|
||||||
| **ThingLink** | Hyperlink for a Thing | `thing` (FK), `url`, `title`, `uploaded_at` |
|
| **ThingLink** | Hyperlink for a Thing | `thing` (FK), `url`, `title`, `uploaded_at` |
|
||||||
|
|
||||||
**Model Relationships:**
|
**Model Relationships:**
|
||||||
- BoxType -> Box (1:N via `boxes` related_name, PROTECT on delete)
|
- BoxType -> Box (1:N via `boxes` related_name, PROTECT on delete)
|
||||||
- Box -> Thing (1:N via `things` 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)
|
- Facet -> Tag (1:N via `tags` related_name, CASCADE on delete)
|
||||||
- ThingType -> ThingType (self-referential tree via MPTT)
|
- 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 -> ThingFile (1:N via `files` related_name, CASCADE on delete)
|
||||||
- Thing -> ThingLink (1:N via `links` 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
|
## Available Django Extensions
|
||||||
|
|
||||||
The project includes these pre-installed packages:
|
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 |
|
| 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 |
|
| `box_management` | `/box-management/` | `box_management` | Manage boxes and box types |
|
||||||
| `add_box_type` | `/box-type/add/` | `add_box_type` | Add new box type |
|
| `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 |
|
| `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 |
|
| `edit_box` | `/box/<str:box_id>/edit/` | `edit_box` | Edit box |
|
||||||
| `delete_box` | `/box/<str:box_id>/delete/` | `delete_box` | Delete box |
|
| `delete_box` | `/box/<str:box_id>/delete/` | `delete_box` | Delete box |
|
||||||
| `box_detail` | `/box/<str:box_id>/` | `box_detail` | View box contents |
|
| `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_detail` | `/thing/<int:thing_id>/` | `thing_detail` | View/edit thing (move, picture, files, links, tags) |
|
||||||
| `thing_type_detail` | `/thing-type/<int:type_id>/` | `thing_type_detail` | View thing type hierarchy |
|
|
||||||
| `add_things` | `/box/<str:box_id>/add/` | `add_things` | Add multiple things to a box |
|
| `add_things` | `/box/<str:box_id>/add/` | `add_things` | Add multiple things to a box |
|
||||||
| `search` | `/search/` | `search` | Search page |
|
| `search` | `/search/` | `search` | Search page |
|
||||||
| `search_api` | `/search/api/` | `search_api` | AJAX search endpoint |
|
| `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
|
### Template Best Practices
|
||||||
|
|
||||||
1. **Always extend base template**
|
1. **Always extend base template**
|
||||||
```django
|
```django
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Use block system for content injection**
|
2. **Use block system for content injection**
|
||||||
- `title`: Page title tag
|
- `title`: Page title tag
|
||||||
- `page_header`: Page header with breadcrumbs
|
- `page_header`: Page header with breadcrumbs
|
||||||
- `content`: Main page content
|
- `content`: Main page content
|
||||||
- `extra_css`: Additional styles
|
- `extra_css`: Additional styles
|
||||||
- `extra_js`: Additional JavaScript
|
- `extra_js`: Additional JavaScript
|
||||||
|
|
||||||
3. **Load required template tags**
|
3. **Load required template tags**
|
||||||
```django
|
```django
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load mptt_tags %}
|
{% load mptt_tags %}
|
||||||
{% load thumbnail %}
|
{% load thumbnail %}
|
||||||
```
|
{% load dict_extras %}
|
||||||
|
```
|
||||||
|
|
||||||
4. **Use URL names for links**
|
4. **Use URL names for links**
|
||||||
```django
|
```django
|
||||||
<a href="{% url 'box_detail' box.id %}">
|
<a href="{% url 'box_detail' box.id %}">
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Use icons with Font Awesome**
|
5. **Use icons with Font Awesome**
|
||||||
```django
|
```django
|
||||||
<i class="fas fa-box"></i>
|
<i class="fas fa-box"></i>
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **Add breadcrumbs for navigation**
|
6. **Add breadcrumbs for navigation**
|
||||||
```django
|
```django
|
||||||
<p class="breadcrumb">
|
<p class="breadcrumb">
|
||||||
<a href="/"><i class="fas fa-home"></i> Home</a> /
|
<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>
|
<a href="/box/{{ box.id }}/"><i class="fas fa-box"></i> Box {{ box.id }}</a>
|
||||||
</p>
|
</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
|
## Forms
|
||||||
|
|
||||||
| Form | Model | Purpose |
|
| 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 |
|
| `ThingPictureForm` | Thing | Upload/change thing picture only |
|
||||||
| `ThingFileForm` | ThingFile | Add file attachment |
|
| `ThingFileForm` | ThingFile | Add file attachment |
|
||||||
| `ThingLinkForm` | ThingLink | Add link |
|
| `ThingLinkForm` | ThingLink | Add link |
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ spec:
|
|||||||
mountPath: /data
|
mountPath: /data
|
||||||
containers:
|
containers:
|
||||||
- name: web
|
- name: web
|
||||||
image: git.baumann.gr/adebaumann/labhelper:0.049
|
image: git.baumann.gr/adebaumann/labhelper:0.050
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8000
|
- containerPort: 8000
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load thumbnail %}
|
{% load thumbnail %}
|
||||||
|
{% load dict_extras %}
|
||||||
|
|
||||||
{% block title %}Box {{ box.id }} - LabHelper{% endblock %}
|
{% block title %}Box {{ box.id }} - LabHelper{% endblock %}
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@
|
|||||||
<span style="color: #999; font-style: italic; font-size: 13px;">-</span>
|
<span style="color: #999; font-style: italic; font-size: 13px;">-</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load thumbnail %}
|
{% load thumbnail %}
|
||||||
|
{% load dict_extras %}
|
||||||
|
|
||||||
{% block title %}{{ thing.name }} - LabHelper{% endblock %}
|
{% block title %}{{ thing.name }} - LabHelper{% endblock %}
|
||||||
|
|
||||||
@@ -95,8 +96,8 @@
|
|||||||
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
|
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
|
||||||
<i class="fas fa-align-left"></i> Description
|
<i class="fas fa-align-left"></i> Description
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size: 16px; color: #555; line-height: 1.6; white-space: pre-wrap;">
|
<div class="markdown-content" style="font-size: 16px; color: #555; line-height: 1.6;">
|
||||||
{% spaceless %}{{ thing.description }}{% endspaceless %}
|
{{ thing.description|render_markdown }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -273,6 +274,85 @@
|
|||||||
|
|
||||||
{% endblock %}
|
{% 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 %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
$('#new_box').on('focus', function() {
|
$('#new_box').on('focus', function() {
|
||||||
|
|||||||
@@ -1,7 +1,95 @@
|
|||||||
|
import bleach
|
||||||
|
import markdown
|
||||||
from django import template
|
from django import template
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def get_item(dictionary, key):
|
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
|
||||||
678
boxes/tests.py
678
boxes/tests.py
@@ -5,8 +5,8 @@ from django.db import IntegrityError
|
|||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from .admin import BoxAdmin, BoxTypeAdmin, ThingAdmin, ThingTypeAdmin
|
from .admin import BoxAdmin, BoxTypeAdmin, ThingAdmin
|
||||||
from .models import Box, BoxType, Thing, ThingFile, ThingLink, ThingType
|
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
|
||||||
|
|
||||||
|
|
||||||
class AuthTestCase(TestCase):
|
class AuthTestCase(TestCase):
|
||||||
@@ -146,75 +146,6 @@ class BoxAdminTests(TestCase):
|
|||||||
self.assertEqual(self.admin.search_fields, ('id',))
|
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):
|
class ThingModelTests(TestCase):
|
||||||
"""Tests for the Thing model."""
|
"""Tests for the Thing model."""
|
||||||
|
|
||||||
@@ -230,10 +161,8 @@ class ThingModelTests(TestCase):
|
|||||||
id='BOX001',
|
id='BOX001',
|
||||||
box_type=self.box_type
|
box_type=self.box_type
|
||||||
)
|
)
|
||||||
self.thing_type = ThingType.objects.create(name='Electronics')
|
|
||||||
self.thing = Thing.objects.create(
|
self.thing = Thing.objects.create(
|
||||||
name='Arduino Uno',
|
name='Arduino Uno',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box
|
box=self.box
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -244,7 +173,6 @@ class ThingModelTests(TestCase):
|
|||||||
def test_thing_creation(self):
|
def test_thing_creation(self):
|
||||||
"""Thing should be created with correct attributes."""
|
"""Thing should be created with correct attributes."""
|
||||||
self.assertEqual(self.thing.name, 'Arduino Uno')
|
self.assertEqual(self.thing.name, 'Arduino Uno')
|
||||||
self.assertEqual(self.thing.thing_type, self.thing_type)
|
|
||||||
self.assertEqual(self.thing.box, self.box)
|
self.assertEqual(self.thing.box, self.box)
|
||||||
|
|
||||||
def test_thing_optional_description(self):
|
def test_thing_optional_description(self):
|
||||||
@@ -275,7 +203,6 @@ class ThingModelTests(TestCase):
|
|||||||
)
|
)
|
||||||
thing = Thing.objects.create(
|
thing = Thing.objects.create(
|
||||||
name='Test Item',
|
name='Test Item',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box,
|
box=self.box,
|
||||||
picture=image
|
picture=image
|
||||||
)
|
)
|
||||||
@@ -287,49 +214,25 @@ class ThingModelTests(TestCase):
|
|||||||
"""Things should be ordered by name."""
|
"""Things should be ordered by name."""
|
||||||
Thing.objects.create(
|
Thing.objects.create(
|
||||||
name='Zeta Item',
|
name='Zeta Item',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box
|
box=self.box
|
||||||
)
|
)
|
||||||
Thing.objects.create(
|
Thing.objects.create(
|
||||||
name='Alpha Item',
|
name='Alpha Item',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box
|
box=self.box
|
||||||
)
|
)
|
||||||
things = list(Thing.objects.values_list('name', flat=True))
|
things = list(Thing.objects.values_list('name', flat=True))
|
||||||
self.assertEqual(things, ['Alpha Item', 'Arduino Uno', 'Zeta Item'])
|
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):
|
def test_thing_box_relationship(self):
|
||||||
"""Thing should be accessible from Box via related_name."""
|
"""Thing should be accessible from Box via related_name."""
|
||||||
self.assertIn(self.thing, self.box.things.all())
|
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):
|
def test_box_protect_on_delete_with_things(self):
|
||||||
"""Deleting a Box with things should raise IntegrityError."""
|
"""Deleting a Box with things should raise IntegrityError."""
|
||||||
with self.assertRaises(IntegrityError):
|
with self.assertRaises(IntegrityError):
|
||||||
self.box.delete()
|
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):
|
class ThingAdminTests(TestCase):
|
||||||
"""Tests for the Thing admin configuration."""
|
"""Tests for the Thing admin configuration."""
|
||||||
|
|
||||||
@@ -342,13 +245,9 @@ class ThingAdminTests(TestCase):
|
|||||||
"""ThingAdmin should display correct fields."""
|
"""ThingAdmin should display correct fields."""
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.admin.list_display,
|
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):
|
def test_search_fields(self):
|
||||||
"""ThingAdmin should search by name and description."""
|
"""ThingAdmin should search by name and description."""
|
||||||
self.assertEqual(self.admin.search_fields, ('name', 'description'))
|
self.assertEqual(self.admin.search_fields, ('name', 'description'))
|
||||||
@@ -395,7 +294,6 @@ class BoxDetailViewTests(AuthTestCase):
|
|||||||
id='BOX001',
|
id='BOX001',
|
||||||
box_type=self.box_type
|
box_type=self.box_type
|
||||||
)
|
)
|
||||||
self.thing_type = ThingType.objects.create(name='Electronics')
|
|
||||||
|
|
||||||
def test_box_detail_returns_200(self):
|
def test_box_detail_returns_200(self):
|
||||||
"""Box detail page should return 200 status."""
|
"""Box detail page should return 200 status."""
|
||||||
@@ -433,20 +331,17 @@ class BoxDetailViewTests(AuthTestCase):
|
|||||||
"""Box detail page should show things in the box."""
|
"""Box detail page should show things in the box."""
|
||||||
Thing.objects.create(
|
Thing.objects.create(
|
||||||
name='Arduino Uno',
|
name='Arduino Uno',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box,
|
box=self.box,
|
||||||
description='A microcontroller board'
|
description='A microcontroller board'
|
||||||
)
|
)
|
||||||
response = self.client.get(f'/box/{self.box.id}/')
|
response = self.client.get(f'/box/{self.box.id}/')
|
||||||
self.assertContains(response, 'Arduino Uno')
|
self.assertContains(response, 'Arduino Uno')
|
||||||
self.assertContains(response, 'Electronics')
|
|
||||||
self.assertContains(response, 'A microcontroller board')
|
self.assertContains(response, 'A microcontroller board')
|
||||||
|
|
||||||
def test_box_detail_shows_no_image_placeholder(self):
|
def test_box_detail_shows_no_image_placeholder(self):
|
||||||
"""Box detail page should show placeholder for things without images."""
|
"""Box detail page should show placeholder for things without images."""
|
||||||
Thing.objects.create(
|
Thing.objects.create(
|
||||||
name='Test Item',
|
name='Test Item',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box
|
box=self.box
|
||||||
)
|
)
|
||||||
response = self.client.get(f'/box/{self.box.id}/')
|
response = self.client.get(f'/box/{self.box.id}/')
|
||||||
@@ -456,12 +351,10 @@ class BoxDetailViewTests(AuthTestCase):
|
|||||||
"""Box detail page should show multiple things."""
|
"""Box detail page should show multiple things."""
|
||||||
Thing.objects.create(
|
Thing.objects.create(
|
||||||
name='Item One',
|
name='Item One',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box
|
box=self.box
|
||||||
)
|
)
|
||||||
Thing.objects.create(
|
Thing.objects.create(
|
||||||
name='Item Two',
|
name='Item Two',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box
|
box=self.box
|
||||||
)
|
)
|
||||||
response = self.client.get(f'/box/{self.box.id}/')
|
response = self.client.get(f'/box/{self.box.id}/')
|
||||||
@@ -489,7 +382,6 @@ class BoxDetailViewTests(AuthTestCase):
|
|||||||
)
|
)
|
||||||
thing = Thing.objects.create(
|
thing = Thing.objects.create(
|
||||||
name='Item With Image',
|
name='Item With Image',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box,
|
box=self.box,
|
||||||
picture=image
|
picture=image
|
||||||
)
|
)
|
||||||
@@ -527,10 +419,8 @@ class ThingDetailViewTests(AuthTestCase):
|
|||||||
id='BOX001',
|
id='BOX001',
|
||||||
box_type=self.box_type
|
box_type=self.box_type
|
||||||
)
|
)
|
||||||
self.thing_type = ThingType.objects.create(name='Electronics')
|
|
||||||
self.thing = Thing.objects.create(
|
self.thing = Thing.objects.create(
|
||||||
name='Arduino Uno',
|
name='Arduino Uno',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box,
|
box=self.box,
|
||||||
description='A microcontroller board'
|
description='A microcontroller board'
|
||||||
)
|
)
|
||||||
@@ -550,10 +440,10 @@ class ThingDetailViewTests(AuthTestCase):
|
|||||||
response = self.client.get(f'/thing/{self.thing.id}/')
|
response = self.client.get(f'/thing/{self.thing.id}/')
|
||||||
self.assertContains(response, 'Arduino Uno')
|
self.assertContains(response, 'Arduino Uno')
|
||||||
|
|
||||||
def test_thing_detail_shows_type(self):
|
def test_thing_detail_shows_tags_section(self):
|
||||||
"""Thing detail page should show thing type."""
|
"""Thing detail page should show tags section."""
|
||||||
response = self.client.get(f'/thing/{self.thing.id}/')
|
response = self.client.get(f'/thing/{self.thing.id}/')
|
||||||
self.assertContains(response, 'Electronics')
|
self.assertContains(response, 'Tags')
|
||||||
|
|
||||||
def test_thing_detail_shows_description(self):
|
def test_thing_detail_shows_description(self):
|
||||||
"""Thing detail page should show thing description."""
|
"""Thing detail page should show thing description."""
|
||||||
@@ -644,16 +534,13 @@ class SearchApiTests(AuthTestCase):
|
|||||||
id='BOX001',
|
id='BOX001',
|
||||||
box_type=self.box_type
|
box_type=self.box_type
|
||||||
)
|
)
|
||||||
self.thing_type = ThingType.objects.create(name='Electronics')
|
|
||||||
Thing.objects.create(
|
Thing.objects.create(
|
||||||
name='Arduino Uno',
|
name='Arduino Uno',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box,
|
box=self.box,
|
||||||
description='A microcontroller board'
|
description='A microcontroller board'
|
||||||
)
|
)
|
||||||
Thing.objects.create(
|
Thing.objects.create(
|
||||||
name='Raspberry Pi',
|
name='Raspberry Pi',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box
|
box=self.box
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -683,7 +570,6 @@ class SearchApiTests(AuthTestCase):
|
|||||||
"""Search API should truncate long descriptions."""
|
"""Search API should truncate long descriptions."""
|
||||||
Thing.objects.create(
|
Thing.objects.create(
|
||||||
name='Long Description Item',
|
name='Long Description Item',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box,
|
box=self.box,
|
||||||
description='A' * 200
|
description='A' * 200
|
||||||
)
|
)
|
||||||
@@ -691,14 +577,14 @@ class SearchApiTests(AuthTestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
results = response.json()['results']
|
results = response.json()['results']
|
||||||
self.assertEqual(len(results), 1)
|
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):
|
def test_search_api_limits_results(self):
|
||||||
"""Search API should limit results to 50."""
|
"""Search API should limit results to 50."""
|
||||||
for i in range(60):
|
for i in range(60):
|
||||||
Thing.objects.create(
|
Thing.objects.create(
|
||||||
name=f'Item {i}',
|
name=f'Item {i}',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box
|
box=self.box
|
||||||
)
|
)
|
||||||
response = self.client.get('/search/api/?q=Item')
|
response = self.client.get('/search/api/?q=Item')
|
||||||
@@ -737,7 +623,6 @@ class AddThingsViewTests(AuthTestCase):
|
|||||||
id='BOX001',
|
id='BOX001',
|
||||||
box_type=self.box_type
|
box_type=self.box_type
|
||||||
)
|
)
|
||||||
self.thing_type = ThingType.objects.create(name='Electronics')
|
|
||||||
|
|
||||||
def test_add_things_get_request(self):
|
def test_add_things_get_request(self):
|
||||||
"""Add things page should return 200 for GET request."""
|
"""Add things page should return 200 for GET request."""
|
||||||
@@ -765,13 +650,10 @@ class AddThingsViewTests(AuthTestCase):
|
|||||||
'form-TOTAL_FORMS': '3',
|
'form-TOTAL_FORMS': '3',
|
||||||
'form-INITIAL_FORMS': '0',
|
'form-INITIAL_FORMS': '0',
|
||||||
'form-0-name': 'Arduino Uno',
|
'form-0-name': 'Arduino Uno',
|
||||||
'form-0-thing_type': self.thing_type.id,
|
|
||||||
'form-0-description': 'A microcontroller',
|
'form-0-description': 'A microcontroller',
|
||||||
'form-1-name': 'LED Strip',
|
'form-1-name': 'LED Strip',
|
||||||
'form-1-thing_type': self.thing_type.id,
|
|
||||||
'form-1-description': 'Lighting component',
|
'form-1-description': 'Lighting component',
|
||||||
'form-2-name': '',
|
'form-2-name': '',
|
||||||
'form-2-thing_type': '',
|
|
||||||
'form-2-description': '',
|
'form-2-description': '',
|
||||||
})
|
})
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
@@ -782,42 +664,23 @@ class AddThingsViewTests(AuthTestCase):
|
|||||||
"""Adding things without name should show error."""
|
"""Adding things without name should show error."""
|
||||||
response = self.client.post(f'/box/{self.box.id}/add/', {
|
response = self.client.post(f'/box/{self.box.id}/add/', {
|
||||||
'form-TOTAL_FORMS': '2',
|
'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.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'This field is required')
|
self.assertContains(response, 'This field is required')
|
||||||
|
|
||||||
def test_add_things_post_partial_valid_invalid(self):
|
def test_add_things_post_partial_valid_and_empty(self):
|
||||||
"""Partial submission: one valid, one missing name - nothing saved due to formset validation."""
|
"""Partial submission: one valid, one empty - only valid ones are saved."""
|
||||||
response = self.client.post(f'/box/{self.box.id}/add/', {
|
response = self.client.post(f'/box/{self.box.id}/add/', {
|
||||||
'form-TOTAL_FORMS': '2',
|
'form-TOTAL_FORMS': '2',
|
||||||
'form-INITIAL_FORMS': '0',
|
'form-INITIAL_FORMS': '0',
|
||||||
'form-0-name': 'Arduino Uno',
|
'form-0-name': 'Arduino Uno',
|
||||||
'form-0-thing_type': self.thing_type.id,
|
|
||||||
'form-1-name': '',
|
'form-1-name': '',
|
||||||
'form-1-thing_type': self.thing_type.id,
|
|
||||||
})
|
})
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'This field is required')
|
# Valid row is saved, empty row is skipped
|
||||||
# Formset validation fails, so nothing is saved
|
self.assertContains(response, 'Added 1 thing successfully')
|
||||||
self.assertEqual(Thing.objects.count(), 0)
|
self.assertEqual(Thing.objects.count(), 1)
|
||||||
|
self.assertEqual(Thing.objects.first().name, 'Arduino Uno')
|
||||||
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)
|
|
||||||
|
|
||||||
def test_add_things_empty_all(self):
|
def test_add_things_empty_all(self):
|
||||||
"""Submitting empty forms should not create anything."""
|
"""Submitting empty forms should not create anything."""
|
||||||
@@ -825,9 +688,7 @@ class AddThingsViewTests(AuthTestCase):
|
|||||||
'form-TOTAL_FORMS': '2',
|
'form-TOTAL_FORMS': '2',
|
||||||
'form-INITIAL_FORMS': '0',
|
'form-INITIAL_FORMS': '0',
|
||||||
'form-0-name': '',
|
'form-0-name': '',
|
||||||
'form-0-thing_type': '',
|
|
||||||
'form-1-name': '',
|
'form-1-name': '',
|
||||||
'form-1-thing_type': '',
|
|
||||||
})
|
})
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(Thing.objects.count(), 0)
|
self.assertEqual(Thing.objects.count(), 0)
|
||||||
@@ -839,16 +700,12 @@ class AddThingsViewTests(AuthTestCase):
|
|||||||
|
|
||||||
def test_add_things_populates_box(self):
|
def test_add_things_populates_box(self):
|
||||||
"""Created things should be assigned to the correct box."""
|
"""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/', {
|
response = self.client.post(f'/box/{self.box.id}/add/', {
|
||||||
'form-TOTAL_FORMS': '3',
|
'form-TOTAL_FORMS': '3',
|
||||||
'form-INITIAL_FORMS': '0',
|
'form-INITIAL_FORMS': '0',
|
||||||
'form-0-name': 'Bolt',
|
'form-0-name': 'Bolt',
|
||||||
'form-0-thing_type': self.thing_type_2.id,
|
|
||||||
'form-1-name': 'Nut',
|
'form-1-name': 'Nut',
|
||||||
'form-1-thing_type': self.thing_type_2.id,
|
|
||||||
'form-2-name': 'Washer',
|
'form-2-name': 'Washer',
|
||||||
'form-2-thing_type': self.thing_type_2.id,
|
|
||||||
})
|
})
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'Added 3 things successfully')
|
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/')
|
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):
|
class LoginViewTests(TestCase):
|
||||||
"""Tests for login view."""
|
"""Tests for login view."""
|
||||||
|
|
||||||
@@ -1198,10 +986,8 @@ class BoxCRUDTests(AuthTestCase):
|
|||||||
|
|
||||||
def test_delete_box_with_things_redirects(self):
|
def test_delete_box_with_things_redirects(self):
|
||||||
"""Deleting a box with things should redirect without deleting."""
|
"""Deleting a box with things should redirect without deleting."""
|
||||||
thing_type = ThingType.objects.create(name='Test')
|
|
||||||
Thing.objects.create(
|
Thing.objects.create(
|
||||||
name='Test Item',
|
name='Test Item',
|
||||||
thing_type=thing_type,
|
|
||||||
box=self.box
|
box=self.box
|
||||||
)
|
)
|
||||||
box_id = self.box.id
|
box_id = self.box.id
|
||||||
@@ -1227,10 +1013,8 @@ class ThingPictureUploadTests(AuthTestCase):
|
|||||||
id='BOX001',
|
id='BOX001',
|
||||||
box_type=self.box_type
|
box_type=self.box_type
|
||||||
)
|
)
|
||||||
self.thing_type = ThingType.objects.create(name='Electronics')
|
|
||||||
self.thing = Thing.objects.create(
|
self.thing = Thing.objects.create(
|
||||||
name='Arduino Uno',
|
name='Arduino Uno',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box
|
box=self.box
|
||||||
)
|
)
|
||||||
self.image_data = (
|
self.image_data = (
|
||||||
@@ -1302,15 +1086,6 @@ class ThingPictureUploadTests(AuthTestCase):
|
|||||||
self.thing.refresh_from_db()
|
self.thing.refresh_from_db()
|
||||||
self.assertFalse(self.thing.picture.name)
|
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):
|
class ThingFileModelTests(AuthTestCase):
|
||||||
"""Tests for the ThingFile model."""
|
"""Tests for the ThingFile model."""
|
||||||
@@ -1328,10 +1103,8 @@ class ThingFileModelTests(AuthTestCase):
|
|||||||
id='BOX001',
|
id='BOX001',
|
||||||
box_type=self.box_type
|
box_type=self.box_type
|
||||||
)
|
)
|
||||||
self.thing_type = ThingType.objects.create(name='Electronics')
|
|
||||||
self.thing = Thing.objects.create(
|
self.thing = Thing.objects.create(
|
||||||
name='Arduino Uno',
|
name='Arduino Uno',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box
|
box=self.box
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1400,10 +1173,8 @@ class ThingLinkModelTests(AuthTestCase):
|
|||||||
id='BOX001',
|
id='BOX001',
|
||||||
box_type=self.box_type
|
box_type=self.box_type
|
||||||
)
|
)
|
||||||
self.thing_type = ThingType.objects.create(name='Electronics')
|
|
||||||
self.thing = Thing.objects.create(
|
self.thing = Thing.objects.create(
|
||||||
name='Arduino Uno',
|
name='Arduino Uno',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box
|
box=self.box
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1472,10 +1243,8 @@ class ThingFileAndLinkCRUDTests(AuthTestCase):
|
|||||||
id='BOX001',
|
id='BOX001',
|
||||||
box_type=self.box_type
|
box_type=self.box_type
|
||||||
)
|
)
|
||||||
self.thing_type = ThingType.objects.create(name='Electronics')
|
|
||||||
self.thing = Thing.objects.create(
|
self.thing = Thing.objects.create(
|
||||||
name='Arduino Uno',
|
name='Arduino Uno',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box
|
box=self.box
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1544,7 +1313,6 @@ class ThingFileAndLinkCRUDTests(AuthTestCase):
|
|||||||
"""Cannot delete file from another thing."""
|
"""Cannot delete file from another thing."""
|
||||||
other_thing = Thing.objects.create(
|
other_thing = Thing.objects.create(
|
||||||
name='Other Item',
|
name='Other Item',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box
|
box=self.box
|
||||||
)
|
)
|
||||||
thing_file = ThingFile.objects.create(
|
thing_file = ThingFile.objects.create(
|
||||||
@@ -1564,7 +1332,6 @@ class ThingFileAndLinkCRUDTests(AuthTestCase):
|
|||||||
"""Cannot delete link from another thing."""
|
"""Cannot delete link from another thing."""
|
||||||
other_thing = Thing.objects.create(
|
other_thing = Thing.objects.create(
|
||||||
name='Other Item',
|
name='Other Item',
|
||||||
thing_type=self.thing_type,
|
|
||||||
box=self.box
|
box=self.box
|
||||||
)
|
)
|
||||||
thing_link = ThingLink.objects.create(
|
thing_link = ThingLink.objects.create(
|
||||||
@@ -1700,3 +1467,420 @@ class ThingFileAndLinkCRUDTests(AuthTestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
results = response.json()['results']
|
results = response.json()['results']
|
||||||
self.assertGreater(len(results), 0)
|
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'])
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import bleach
|
||||||
|
import markdown
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import HttpResponse, JsonResponse
|
from django.http import HttpResponse, JsonResponse
|
||||||
@@ -14,6 +17,18 @@ from .forms import (
|
|||||||
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
|
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
|
@login_required
|
||||||
def index(request):
|
def index(request):
|
||||||
"""Home page with boxes and tags."""
|
"""Home page with boxes and tags."""
|
||||||
@@ -198,7 +213,7 @@ def search_api(request):
|
|||||||
'id': thing.id,
|
'id': thing.id,
|
||||||
'name': thing.name,
|
'name': thing.name,
|
||||||
'box': thing.box.id,
|
'box': thing.box.id,
|
||||||
'description': thing.description[:100] if thing.description else '',
|
'description': _strip_markdown(thing.description),
|
||||||
'tags': [
|
'tags': [
|
||||||
{
|
{
|
||||||
'name': tag.name,
|
'name': tag.name,
|
||||||
@@ -240,7 +255,7 @@ def add_things(request, box_id):
|
|||||||
things = formset.save(commit=False)
|
things = formset.save(commit=False)
|
||||||
created_count = 0
|
created_count = 0
|
||||||
for thing in things:
|
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.box = box
|
||||||
thing.save()
|
thing.save()
|
||||||
created_count += 1
|
created_count += 1
|
||||||
|
|||||||
Reference in New Issue
Block a user