Compare commits
12 Commits
ca50832b54
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
| d46f0385c9 | |||
| ebf3b9d00a | |||
| be2a0028f4 | |||
| 4efaf17776 | |||
| d02f6d1d1d | |||
| 9599807752 | |||
| 56db405839 | |||
| 074f9263dd | |||
| 35140e9686 | |||
| b756e1b411 | |||
| 11e593f8ce | |||
| da506221f7 |
29
AGENTS.md
29
AGENTS.md
@@ -214,9 +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 with boxes and tags
|
│ │ ├── boxes_list.html # Boxes list page with tabular view
|
||||||
│ │ ├── search.html # Search page with AJAX
|
│ │ ├── edit_thing.html # Edit thing page (name, description, picture, tags, files, links)
|
||||||
│ │ └── thing_detail.html # Thing details view with tags
|
│ │ ├── index.html # Home page with search and tags
|
||||||
|
│ │ ├── resources_list.html # List all links and files from things
|
||||||
|
│ │ └── thing_detail.html # Read-only thing details view
|
||||||
│ ├── templatetags/
|
│ ├── templatetags/
|
||||||
│ │ └── dict_extras.py # Custom template filters: get_item, render_markdown, truncate_markdown
|
│ │ └── dict_extras.py # Custom template filters: get_item, render_markdown, truncate_markdown
|
||||||
│ ├── admin.py # Admin configuration
|
│ ├── admin.py # Admin configuration
|
||||||
@@ -328,7 +330,7 @@ The project uses a base template system at `labhelper/templates/base.html`. All
|
|||||||
- Cards: White with subtle shadows
|
- Cards: White with subtle shadows
|
||||||
|
|
||||||
**Components:**
|
**Components:**
|
||||||
- **Navigation**: Glassmorphism effect with blur backdrop
|
- **Navigation**: Glassmorphism effect with blur backdrop. Desktop (≥769px) shows horizontal menu with dropdown for authenticated user (contains Box Management, Resources, Admin, Logout)
|
||||||
- **Buttons**: Gradient backgrounds with hover lift effect
|
- **Buttons**: Gradient backgrounds with hover lift effect
|
||||||
- **Cards**: White with rounded corners and box shadows
|
- **Cards**: White with rounded corners and box shadows
|
||||||
- **Tables**: Gradient headers with hover row effects
|
- **Tables**: Gradient headers with hover row effects
|
||||||
@@ -350,6 +352,7 @@ The project uses a base template system at `labhelper/templates/base.html`. All
|
|||||||
- Grid layouts with `repeat(auto-fill, minmax(250px, 1fr))`
|
- Grid layouts with `repeat(auto-fill, minmax(250px, 1fr))`
|
||||||
- Flexbox for component layouts
|
- Flexbox for component layouts
|
||||||
- Breakpoints handled by grid and flex-wrap
|
- Breakpoints handled by grid and flex-wrap
|
||||||
|
- **Navigation**: Responsive navbar with hamburger menu on mobile (≤768px) and horizontal menu with user dropdown on desktop (≥769px). Mobile keeps all items in the dropdown list
|
||||||
|
|
||||||
### CSS Guidelines
|
### CSS Guidelines
|
||||||
|
|
||||||
@@ -375,7 +378,8 @@ 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 tags overview |
|
| `index` | `/` | `index` | Home page with search and tags overview |
|
||||||
|
| `boxes_list` | `/search/` | `search`, `boxes_list` | Boxes list page with tabular view |
|
||||||
| `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 |
|
||||||
@@ -384,10 +388,11 @@ 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, tags) |
|
| `thing_detail` | `/thing/<int:thing_id>/` | `thing_detail` | Read-only view of thing details |
|
||||||
|
| `edit_thing` | `/thing/<int:thing_id>/edit/` | `edit_thing` | Edit thing (name, description, picture, tags, files, links, move) |
|
||||||
| `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_api` | `/search/api/` | `search_api` | AJAX search endpoint |
|
| `search_api` | `/search/api/` | `search_api` | AJAX search endpoint |
|
||||||
|
| `resources_list` | `/resources/` | `resources_list` | List all links and files from things (sorted by thing name) |
|
||||||
| `LoginView` | `/login/` | `login` | Django auth login |
|
| `LoginView` | `/login/` | `login` | Django auth login |
|
||||||
| `LogoutView` | `/logout/` | `logout` | Django auth logout |
|
| `LogoutView` | `/logout/` | `logout` | Django auth logout |
|
||||||
| `admin.site` | `/admin/` | - | Django admin |
|
| `admin.site` | `/admin/` | - | Django admin |
|
||||||
@@ -434,6 +439,13 @@ The project uses a base template system at `labhelper/templates/base.html`. All
|
|||||||
</p>
|
</p>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
7. **Icon alignment in lists**: When using icons in list items, use fixed width containers to ensure proper alignment
|
||||||
|
```django
|
||||||
|
<a href="{% url 'thing_detail' thing.id %}" style="display: inline-block; width: 20px; text-align: center;">
|
||||||
|
<i class="fas fa-link"></i>
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
### Markdown Support
|
### Markdown Support
|
||||||
|
|
||||||
The `Thing.description` field supports Markdown formatting with HTML sanitization for security.
|
The `Thing.description` field supports Markdown formatting with HTML sanitization for security.
|
||||||
@@ -482,7 +494,7 @@ The `Thing.description` field supports Markdown formatting with HTML sanitizatio
|
|||||||
|
|
||||||
| Form | Model | Purpose |
|
| Form | Model | Purpose |
|
||||||
|------|-------|---------|
|
|------|-------|---------|
|
||||||
| `ThingForm` | Thing | Add/edit a thing (name, description, picture, tags) |
|
| `ThingForm` | Thing | Add/edit a thing (name, description, picture) - tags managed separately |
|
||||||
| `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 |
|
||||||
@@ -547,6 +559,7 @@ Per `.gitignore`:
|
|||||||
5. **Templates in labhelper/templates/**: The base template and shared templates are in `labhelper/templates/`. App-specific templates remain in `app_name/templates/`.
|
5. **Templates in labhelper/templates/**: The base template and shared templates are in `labhelper/templates/`. App-specific templates remain in `app_name/templates/`.
|
||||||
6. **Use get_object_or_404** instead of bare `.get()` calls
|
6. **Use get_object_or_404** instead of bare `.get()` calls
|
||||||
7. **Never commit SECRET_KEY** - use environment variables in production
|
7. **Never commit SECRET_KEY** - use environment variables in production
|
||||||
|
8. **Be careful with process management**: Avoid blanket kills on ports (e.g., `lsof -ti:8000 | xargs kill -9`) as they can kill unintended processes like web browsers. Use specific process kills instead: `pkill -f "process_name"`
|
||||||
|
|
||||||
## Deployment Commands
|
## Deployment Commands
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteMany
|
- ReadWriteMany
|
||||||
storageClassName: nfs
|
storageClassName: nfs-labhelper
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: 2Gi
|
storage: 2Gi
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ spec:
|
|||||||
mountPath: /data
|
mountPath: /data
|
||||||
containers:
|
containers:
|
||||||
- name: web
|
- name: web
|
||||||
image: git.baumann.gr/adebaumann/labhelper:0.050
|
image: git.baumann.gr/adebaumann/labhelper:0.058
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8000
|
- containerPort: 8000
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ spec:
|
|||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteMany
|
- ReadWriteMany
|
||||||
persistentVolumeReclaimPolicy: Retain
|
persistentVolumeReclaimPolicy: Retain
|
||||||
storageClassName: nfs
|
storageClassName: nfs-labhelper
|
||||||
nfs:
|
nfs:
|
||||||
server: 192.168.17.199
|
server: 192.168.17.199
|
||||||
path: /mnt/user/labhelper
|
path: /mnt/user/labhelper
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
apiVersion: storage.k8s.io/v1
|
apiVersion: storage.k8s.io/v1
|
||||||
kind: StorageClass
|
kind: StorageClass
|
||||||
metadata:
|
metadata:
|
||||||
name: nfs
|
name: nfs-labhelper
|
||||||
provisioner: kubernetes.io/no-provisioner
|
provisioner: kubernetes.io/no-provisioner
|
||||||
allowVolumeExpansion: true
|
allowVolumeExpansion: true
|
||||||
reclaimPolicy: Retain
|
reclaimPolicy: Retain
|
||||||
volumeBindingMode: Immediate
|
volumeBindingMode: Immediate
|
||||||
|
|||||||
@@ -1,10 +1,43 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.contrib.admin import SimpleListFilter
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
|
|
||||||
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
|
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
|
||||||
|
|
||||||
|
|
||||||
|
class BoxFilter(SimpleListFilter):
|
||||||
|
"""Custom filter for boxes using pk to avoid spaces in aliases."""
|
||||||
|
|
||||||
|
title = 'box'
|
||||||
|
parameter_name = 'box__pk'
|
||||||
|
|
||||||
|
def lookups(self, request, model_admin):
|
||||||
|
boxes = Box.objects.select_related('box_type').order_by('id')
|
||||||
|
return [(box.pk, str(box)) for box in boxes]
|
||||||
|
|
||||||
|
def queryset(self, request, queryset):
|
||||||
|
if self.value():
|
||||||
|
return queryset.filter(box__pk=self.value())
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
class TagsFilter(SimpleListFilter):
|
||||||
|
"""Custom filter for tags using pk to avoid spaces in aliases."""
|
||||||
|
|
||||||
|
title = 'tags'
|
||||||
|
parameter_name = 'tags__pk'
|
||||||
|
|
||||||
|
def lookups(self, request, model_admin):
|
||||||
|
tags = Tag.objects.select_related('facet').order_by('facet__name', 'name')
|
||||||
|
return [(tag.pk, str(tag)) for tag in tags]
|
||||||
|
|
||||||
|
def queryset(self, request, queryset):
|
||||||
|
if self.value():
|
||||||
|
return queryset.filter(tags__pk=self.value())
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
@admin.register(BoxType)
|
@admin.register(BoxType)
|
||||||
class BoxTypeAdmin(admin.ModelAdmin):
|
class BoxTypeAdmin(admin.ModelAdmin):
|
||||||
"""Admin configuration for BoxType model."""
|
"""Admin configuration for BoxType model."""
|
||||||
@@ -42,7 +75,7 @@ class ThingAdmin(admin.ModelAdmin):
|
|||||||
"""Admin configuration for Thing model."""
|
"""Admin configuration for Thing model."""
|
||||||
|
|
||||||
list_display = ('name', 'box')
|
list_display = ('name', 'box')
|
||||||
list_filter = ('box', 'tags')
|
list_filter = (BoxFilter, TagsFilter)
|
||||||
search_fields = ('name', 'description')
|
search_fields = ('name', 'description')
|
||||||
filter_horizontal = ('tags',)
|
filter_horizontal = ('tags',)
|
||||||
inlines = [ThingFileInline, ThingLinkInline]
|
inlines = [ThingFileInline, ThingLinkInline]
|
||||||
|
|||||||
@@ -4,15 +4,14 @@ from .models import Box, BoxType, Thing, ThingFile, ThingLink
|
|||||||
|
|
||||||
|
|
||||||
class ThingForm(forms.ModelForm):
|
class ThingForm(forms.ModelForm):
|
||||||
"""Form for adding a Thing."""
|
"""Form for adding/editing a Thing."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Thing
|
model = Thing
|
||||||
fields = ('name', 'description', 'picture', 'tags')
|
fields = ('name', 'description', 'picture')
|
||||||
widgets = {
|
widgets = {
|
||||||
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
|
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
|
||||||
'tags': forms.CheckboxSelectMultiple(attrs={'class': 'tags-checkboxes'}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
74
boxes/templates/boxes/boxes_list.html
Normal file
74
boxes/templates/boxes/boxes_list.html
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Boxes - LabHelper{% endblock %}
|
||||||
|
|
||||||
|
{% block page_header %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1><i class="fas fa-boxes"></i> Boxes</h1>
|
||||||
|
<p class="breadcrumb">
|
||||||
|
<a href="/"><i class="fas fa-home"></i> Home</a> / Boxes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if boxes %}
|
||||||
|
<div class="section" style="overflow-x: auto;">
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
|
||||||
|
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Box ID</th>
|
||||||
|
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Type</th>
|
||||||
|
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Dimensions (mm)</th>
|
||||||
|
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Contents</th>
|
||||||
|
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Item Count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for box in boxes %}
|
||||||
|
<tr style="border-bottom: 1px solid #e0e0e0; transition: background 0.2s;" class="box-row">
|
||||||
|
<td style="padding: 15px 20px; font-weight: 700; color: #667eea;">
|
||||||
|
<a href="{% url 'box_detail' box.id %}" style="text-decoration: none; color: inherit;">Box {{ box.id }}</a>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 15px 20px;">{{ box.box_type.name }}</td>
|
||||||
|
<td style="padding: 15px 20px;">{{ box.box_type.width }} x {{ box.box_type.height }} x {{ box.box_type.length }}</td>
|
||||||
|
<td style="padding: 15px 20px; color: #555;">
|
||||||
|
{% if box.things.all %}
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 5px;">
|
||||||
|
{% for thing in box.things.all %}
|
||||||
|
<a href="{% url 'thing_detail' thing.id %}" style="display: inline-block; padding: 4px 10px; background: #f0f0f0; border-radius: 12px; text-decoration: none; color: #333; font-size: 13px; font-weight: 500;">{{ thing.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: #999; font-style: italic;">Empty</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 15px 20px; font-weight: 600;">{{ box.things.count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="section" style="text-align: center; padding: 60px 30px;">
|
||||||
|
<i class="fas fa-box-open" style="font-size: 64px; color: #ddd; margin-bottom: 20px; display: block;"></i>
|
||||||
|
<h3 style="color: #888; font-size: 20px;">No boxes found</h3>
|
||||||
|
<p style="color: #999; margin-top: 10px;">Create your first box to get started.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('.box-row').hover(
|
||||||
|
function() {
|
||||||
|
$(this).css('background', '#f8f9fa');
|
||||||
|
},
|
||||||
|
function() {
|
||||||
|
$(this).css('background', 'white');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
324
boxes/templates/boxes/edit_thing.html
Normal file
324
boxes/templates/boxes/edit_thing.html
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load thumbnail %}
|
||||||
|
{% load dict_extras %}
|
||||||
|
|
||||||
|
{% block title %}Edit {{ thing.name }} - LabHelper{% endblock %}
|
||||||
|
|
||||||
|
{% block page_header %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1><i class="fas fa-edit"></i> Edit {{ thing.name }}</h1>
|
||||||
|
<p class="breadcrumb">
|
||||||
|
<a href="/"><i class="fas fa-home"></i> Home</a> /
|
||||||
|
<a href="/box/{{ thing.box.id }}/"><i class="fas fa-box"></i> Box {{ thing.box.id }}</a> /
|
||||||
|
<a href="{% url 'thing_detail' thing.id %}">{{ thing.name }}</a> /
|
||||||
|
Edit
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="section">
|
||||||
|
<h2 style="color: #667eea; font-size: 20px; font-weight: 700; margin-top: 0; margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
|
||||||
|
<i class="fas fa-info-circle"></i> Basic Information
|
||||||
|
</h2>
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="save_details">
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px,1fr)); gap: 20px;">
|
||||||
|
<div>
|
||||||
|
<label for="id_name" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">
|
||||||
|
<i class="fas fa-cube"></i> Name
|
||||||
|
</label>
|
||||||
|
{{ thing_form.name }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="id_description" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">
|
||||||
|
<i class="fas fa-align-left"></i> Description (Markdown)
|
||||||
|
</label>
|
||||||
|
{{ thing_form.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 20px;">
|
||||||
|
<button type="submit" class="btn">
|
||||||
|
<i class="fas fa-save"></i> Save Changes
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'thing_detail' thing.id %}" class="btn" style="background: linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%);">
|
||||||
|
<i class="fas fa-times"></i> Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div style="display: flex; gap: 40px; flex-wrap: wrap;">
|
||||||
|
<div class="thing-image" style="flex-shrink: 0; width: 100%; max-width: 400px;">
|
||||||
|
{% if thing.picture %}
|
||||||
|
{% thumbnail thing.picture "400x400" crop="center" as thumb %}
|
||||||
|
<img src="{{ thumb.url }}" alt="{{ thing.name }}" style="width: 100%; height: auto; max-height: 400px; object-fit: cover; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);">
|
||||||
|
{% endthumbnail %}
|
||||||
|
{% else %}
|
||||||
|
<div style="width: 100%; aspect-ratio: 1; max-width: 400px; max-height: 400px; background: linear-gradient(135deg, #e0e0e0 0%, #f0f0f0 100%); display: flex; align-items: center; justify-content: center; color: #999; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);">
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<i class="fas fa-image" style="font-size: 64px; margin-bottom: 15px; display: block;"></i>
|
||||||
|
No image
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data" style="margin-top: 20px;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="upload_picture">
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||||
|
<label class="btn" style="cursor: pointer; display: inline-flex; align-items: center; gap: 8px;">
|
||||||
|
<i class="fas fa-camera"></i>
|
||||||
|
<span>{% if thing.picture %}Change picture{% else %}Add picture{% endif %}</span>
|
||||||
|
<input type="file" id="picture_upload" name="picture" accept="image/*" style="display: none;" onchange="this.form.submit();">
|
||||||
|
</label>
|
||||||
|
{% if thing.picture %}
|
||||||
|
<button type="submit" name="action" value="delete_picture" class="btn" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); border: none;" onclick="return confirm('Are you sure you want to delete this picture?');">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
<span>Remove</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="thing-details" style="flex-grow: 1; min-width: 300px;">
|
||||||
|
<div class="detail-row" style="margin-bottom: 25px;">
|
||||||
|
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
|
||||||
|
<i class="fas fa-tags"></i> Tags
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px;">
|
||||||
|
{% regroup thing.tags.all by facet as facet_list %}
|
||||||
|
{% for facet in facet_list %}
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 12px; color: {{ facet.grouper.color }}; font-weight: 700; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||||
|
{{ facet.grouper.name }}
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
|
||||||
|
{% for tag in facet.list %}
|
||||||
|
<form method="post" style="display: inline;" onsubmit="return confirm('Remove tag {{ tag.facet.name }}:{{ tag.name }}?');">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="remove_tag">
|
||||||
|
<input type="hidden" name="tag_id" value="{{ tag.id }}">
|
||||||
|
<button type="submit" style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: {{ facet.grouper.color }}20; color: {{ facet.grouper.color }}; border: 2px solid {{ facet.grouper.color }}; border-radius: 20px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.3s;">
|
||||||
|
{{ tag.name }}
|
||||||
|
<i class="fas fa-times" style="font-size: 12px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row" style="margin-bottom: 25px;">
|
||||||
|
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
|
||||||
|
<i class="fas fa-map-marker-alt"></i> Location
|
||||||
|
</div>
|
||||||
|
<form method="post" style="display: inline;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="move">
|
||||||
|
<div style="display: flex; align-items: center; gap: 15px; flex-wrap: wrap;">
|
||||||
|
<select name="new_box" style="padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 15px; background: white; cursor: pointer; transition: all 0.3s;">
|
||||||
|
{% for box in boxes %}
|
||||||
|
<option value="{{ box.id }}" {% if box.id == thing.box.id %}selected{% endif %}>
|
||||||
|
Box {{ box.id }} ({{ box.box_type.name }})
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn" style="height: 42px;">
|
||||||
|
<i class="fas fa-arrows-alt"></i> Move
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if thing.files.all %}
|
||||||
|
<div class="detail-row" style="margin-bottom: 25px;">
|
||||||
|
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
|
||||||
|
<i class="fas fa-file-alt"></i> Files
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 10px;">
|
||||||
|
{% for file in thing.files.all %}
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<i class="fas fa-paperclip" style="color: #667eea; font-size: 16px;"></i>
|
||||||
|
<a href="{{ file.file.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ file.title }}</a>
|
||||||
|
<span style="color: #999; font-size: 12px;">({{ file.filename }})</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this file?');">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="delete_file">
|
||||||
|
<input type="hidden" name="file_id" value="{{ file.id }}">
|
||||||
|
<button type="submit" style="background: none; border: none; cursor: pointer; color: #e74c3c; padding: 5px; transition: all 0.3s;" onmouseover="this.style.color='#c0392b'" onmouseout="this.style.color='#e74c3c'">
|
||||||
|
<i class="fas fa-times" style="font-size: 14px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if thing.links.all %}
|
||||||
|
<div class="detail-row" style="margin-bottom: 25px;">
|
||||||
|
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
|
||||||
|
<i class="fas fa-link"></i> Links
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 10px;">
|
||||||
|
{% for link in thing.links.all %}
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<i class="fas fa-external-link-alt" style="color: #667eea; font-size: 16px;"></i>
|
||||||
|
<a href="{{ link.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ link.title }}</a>
|
||||||
|
</div>
|
||||||
|
<form method="post" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this link?');">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="delete_link">
|
||||||
|
<input type="hidden" name="link_id" value="{{ link.id }}">
|
||||||
|
<button type="submit" style="background: none; border: none; cursor: pointer; color: #e74c3c; padding: 5px; transition: all 0.3s;" onmouseover="this.style.color='#c0392b'" onmouseout="this.style.color='#e74c3c'">
|
||||||
|
<i class="fas fa-times" style="font-size: 14px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 style="color: #667eea; font-size: 20px; font-weight: 700; margin-top: 0; margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
|
||||||
|
<i class="fas fa-plus-circle"></i> Add Tags
|
||||||
|
</h2>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px,1fr)); gap: 30px;">
|
||||||
|
<div>
|
||||||
|
<h3 style="margin: 0 0 15px 0; color: #667eea; font-size: 16px; font-weight: 600;">
|
||||||
|
<i class="fas fa-tag"></i> Add Tag
|
||||||
|
</h3>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="add_tag">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 15px;">
|
||||||
|
<div>
|
||||||
|
<label for="tag_select" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">Select Tag</label>
|
||||||
|
<select name="tag_id" id="tag_select" style="width: 100%; padding: 12px 16px; border: 2px solid #e0e0e0; border-radius: 10px; font-size: 15px; background: white; cursor: pointer; transition: all 0.3s;">
|
||||||
|
<option value="">-- Select a tag --</option>
|
||||||
|
{% for facet in facets %}
|
||||||
|
<optgroup label="{{ facet.name }} ({{ facet.get_cardinality_display }})">
|
||||||
|
{% for tag in facet.tags.all %}
|
||||||
|
{% if tag not in thing.tags.all %}
|
||||||
|
<option value="{{ tag.id }}">
|
||||||
|
{{ tag.name }}
|
||||||
|
</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">
|
||||||
|
<i class="fas fa-plus"></i> Add Tag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 style="color: #667eea; font-size: 20px; font-weight: 700; margin-top: 0; margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
|
||||||
|
<i class="fas fa-plus-circle"></i> Add Attachments
|
||||||
|
</h2>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px,1fr)); gap: 30px;">
|
||||||
|
<div>
|
||||||
|
<h3 style="margin: 0 0 15px 0; color: #667eea; font-size: 16px; font-weight: 600;">
|
||||||
|
<i class="fas fa-file-upload"></i> Upload File
|
||||||
|
</h3>
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="add_file">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 15px;">
|
||||||
|
<div>
|
||||||
|
<label for="file_title" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">Title</label>
|
||||||
|
<input type="text" id="file_title" name="title" style="width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="file_upload" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">File</label>
|
||||||
|
<input type="file" id="file_upload" name="file" style="width: 100%; padding: 10px 15px; border: 2px dashed #e0e0e0; border-radius: 8px; font-size: 14px; background: #f8f9fa;">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">
|
||||||
|
<i class="fas fa-upload"></i> Upload File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style="margin: 0 0 15px 0; color: #667eea; font-size: 16px; font-weight: 600;">
|
||||||
|
<i class="fas fa-link"></i> Add Link
|
||||||
|
</h3>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="add_link">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 15px;">
|
||||||
|
<div>
|
||||||
|
<label for="link_title" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">Title</label>
|
||||||
|
<input type="text" id="link_title" name="title" style="width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="link_url" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">URL</label>
|
||||||
|
<input type="url" id="link_url" name="url" style="width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">
|
||||||
|
<i class="fas fa-plus"></i> Add Link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
#id_name, #id_description {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
background: white;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
#id_name:focus, #id_description:focus {
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
#id_description {
|
||||||
|
min-height: 120px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.detail-row {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
$('#tag_select').on('focus', function() {
|
||||||
|
$(this).css('border-color', '#667eea');
|
||||||
|
$(this).css('box-shadow', '0 0 0 3px rgba(102, 126, 234, 0.1)');
|
||||||
|
}).on('blur', function() {
|
||||||
|
$(this).css('border-color', '#e0e0e0');
|
||||||
|
$(this).css('box-shadow', 'none');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
145
boxes/templates/boxes/fixme.html
Normal file
145
boxes/templates/boxes/fixme.html
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Fixme - LabHelper{% endblock %}
|
||||||
|
|
||||||
|
{% block page_header %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1><i class="fas fa-exclamation-triangle"></i> Fixme</h1>
|
||||||
|
<p class="breadcrumb">Find and fix things missing tags for specific facets</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="section">
|
||||||
|
<h2><i class="fas fa-tags"></i> Select a Facet</h2>
|
||||||
|
|
||||||
|
{% if facets %}
|
||||||
|
<form method="get" action="{% url 'fixme' %}" class="facet-selector">
|
||||||
|
<select name="facet_id" id="facet-select" onchange="this.form.submit()" class="form-control">
|
||||||
|
<option value="">-- Choose a facet --</option>
|
||||||
|
{% for facet in facets %}
|
||||||
|
<option value="{{ facet.id }}" {% if selected_facet and selected_facet.id == facet.id %}selected{% endif %}>
|
||||||
|
{{ facet.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<noscript>
|
||||||
|
<button type="submit" class="btn btn-sm">
|
||||||
|
<i class="fas fa-search"></i> Show Missing Things
|
||||||
|
</button>
|
||||||
|
</noscript>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: #888;">No facets found. Please create some facets first.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if selected_facet %}
|
||||||
|
<div class="section">
|
||||||
|
<h2><i class="fas fa-exclamation-circle"></i> Things Missing "{{ selected_facet.name }}" Tags</h2>
|
||||||
|
|
||||||
|
{% if missing_things %}
|
||||||
|
<form method="post" action="{% url 'fixme' %}" id="fixme-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="facet_id" value="{{ selected_facet.id }}">
|
||||||
|
|
||||||
|
<div class="tags-selection" style="margin-bottom: 20px;">
|
||||||
|
<h3><i class="fas fa-plus-circle"></i> Add Tags:</h3>
|
||||||
|
{% if selected_facet.tags.all %}
|
||||||
|
{% for tag in selected_facet.tags.all %}
|
||||||
|
<label style="display: inline-block; margin-right: 15px; margin-bottom: 10px;">
|
||||||
|
<input type="checkbox" name="tag_ids" value="{{ tag.id }}"
|
||||||
|
{% if selected_facet.cardinality == 'single' %}onclick="uncheckOtherTags(this)"{% endif %}>
|
||||||
|
<span style="background: {{ tag.facet.color }}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;">
|
||||||
|
{{ tag.name }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
{% if selected_facet.cardinality == 'single' %}
|
||||||
|
<p style="color: #888; font-size: 12px; margin-top: 10px;">
|
||||||
|
<i class="fas fa-info-circle"></i> This facet allows only one tag per thing.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p style="color: #888;">No tags available for this facet. Please create some tags first.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="things-list" style="margin-bottom: 20px;">
|
||||||
|
<h3><i class="fas fa-box"></i> Things to Update:</h3>
|
||||||
|
{% for thing in missing_things %}
|
||||||
|
<div style="background: #f8f9fa; padding: 10px; margin: 5px 0; border-radius: 5px; border-left: 4px solid #667eea;">
|
||||||
|
<label style="display: block; cursor: pointer; width: 100%;">
|
||||||
|
<input type="checkbox" name="thing_ids" value="{{ thing.id }}" style="margin-right: 10px;">
|
||||||
|
<strong>{{ thing.name }}</strong>
|
||||||
|
<span style="color: #888; margin-left: 10px;">(Box: {{ thing.box.id }})</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn">
|
||||||
|
<i class="fas fa-save"></i> Add Selected Tags to Selected Things
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'fixme' %}" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-times"></i> Clear Selection
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: #28a745;">
|
||||||
|
<i class="fas fa-check-circle"></i> All things have tags for "{{ selected_facet.name }}" facet!
|
||||||
|
</p>
|
||||||
|
<a href="{% url 'fixme' %}" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-arrow-left"></i> Back to Facet Selection
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
function uncheckOtherTags(checkbox) {
|
||||||
|
// For single cardinality facets, uncheck all other checkboxes
|
||||||
|
var checkboxes = document.querySelectorAll('input[name="tag_ids"]');
|
||||||
|
checkboxes.forEach(function(cb) {
|
||||||
|
if (cb !== checkbox) {
|
||||||
|
cb.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add select all/none functionality for things
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var fixmeForm = document.getElementById('fixme-form');
|
||||||
|
if (fixmeForm) {
|
||||||
|
var thingsList = fixmeForm.querySelector('.things-list');
|
||||||
|
if (thingsList) {
|
||||||
|
var header = document.createElement('div');
|
||||||
|
header.style.marginBottom = '10px';
|
||||||
|
header.innerHTML = `
|
||||||
|
<small>
|
||||||
|
<a href="#" onclick="selectAllThings(); return false;">Select All</a> |
|
||||||
|
<a href="#" onclick="deselectAllThings(); return false;">Deselect All</a>
|
||||||
|
</small>
|
||||||
|
`;
|
||||||
|
thingsList.insertBefore(header, thingsList.firstChild.nextSibling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectAllThings() {
|
||||||
|
var checkboxes = document.querySelectorAll('input[name="thing_ids"]');
|
||||||
|
checkboxes.forEach(function(cb) { cb.checked = true; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectAllThings() {
|
||||||
|
var checkboxes = document.querySelectorAll('input[name="thing_ids"]');
|
||||||
|
checkboxes.forEach(function(cb) { cb.checked = false; });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
@@ -11,34 +11,37 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2><i class="fas fa-box"></i> Boxes</h2>
|
<input type="text"
|
||||||
{% if boxes %}
|
id="search-input"
|
||||||
<div class="box-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 20px;">
|
placeholder="Search for things..."
|
||||||
{% for box in boxes %}
|
style="width: 100%; padding: 16px 20px; font-size: 18px; border: 2px solid #e0e0e0; border-radius: 12px; box-sizing: border-box; transition: all 0.3s;"
|
||||||
<div class="box-card" style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); padding: 20px; border-radius: 12px; border: 1px solid #e0e0e0; transition: all 0.3s ease; cursor: pointer;">
|
{% if request.GET.q %}value="{{ request.GET.q }}"{% endif %}>
|
||||||
<a href="{% url 'box_detail' box.id %}" style="text-decoration: none; color: #333; display: block;">
|
<p style="color: #888; font-size: 14px; margin-top: 10px;">
|
||||||
<div class="box-id" style="font-size: 20px; font-weight: 700; color: #667eea; margin-bottom: 8px;">
|
<i class="fas fa-info-circle"></i> Type at least 2 characters to search
|
||||||
<i class="fas fa-cube"></i> Box {{ box.id }}
|
|
||||||
</div>
|
|
||||||
<div class="box-type" style="font-size: 15px; color: #555; margin-bottom: 5px;">
|
|
||||||
{{ box.box_type.name }}
|
|
||||||
</div>
|
|
||||||
<div class="box-type" style="font-size: 13px; color: #777; margin-bottom: 5px;">
|
|
||||||
<i class="fas fa-ruler-combined"></i> {{ box.box_type.width }} x {{ box.box_type.height }} x {{ box.box_type.length }} mm
|
|
||||||
</div>
|
|
||||||
<div class="box-type" style="font-size: 13px; color: #777;">
|
|
||||||
<i class="fas fa-layer-group"></i> {{ box.things.count }} item{{ box.things.count|pluralize }}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p style="text-align: center; color: #888; font-size: 16px; padding: 40px;">
|
|
||||||
<i class="fas fa-box-open" style="font-size: 48px; margin-bottom: 15px; display: block;"></i>
|
|
||||||
No boxes found.
|
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
</div>
|
||||||
|
|
||||||
|
<div id="results-container" style="display: none;">
|
||||||
|
<div class="section" style="overflow-x: auto; padding: 0;">
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
|
||||||
|
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Name</th>
|
||||||
|
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Tags</th>
|
||||||
|
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Box</th>
|
||||||
|
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="results-body">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="no-results" class="section" style="text-align: center; padding: 60px 30px; display: none;">
|
||||||
|
<i class="fas fa-search-minus" style="font-size: 64px; color: #ddd; margin-bottom: 20px; display: block;"></i>
|
||||||
|
<h3 style="color: #888; font-size: 20px;">No results found</h3>
|
||||||
|
<p style="color: #999; margin-top: 10px;">Try different keywords or browse the full inventory.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -57,7 +60,7 @@
|
|||||||
<div class="facet-tags" style="padding: 15px 20px; display: none;">
|
<div class="facet-tags" style="padding: 15px 20px; display: none;">
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
|
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
|
||||||
{% for tag, count in tags_with_counts %}
|
{% for tag, count in tags_with_counts %}
|
||||||
<a href="/search/?q={{ facet.name }}:{{ tag.name }}" style="display: inline-block; padding: 6px 12px; background: {{ facet.color }}20; color: {{ facet.color }}; border: 1px solid {{ facet.color }}; border-radius: 15px; text-decoration: none; font-size: 14px; font-weight: 600; transition: all 0.2s;">
|
<a href="/?q={{ facet.name }}:{{ tag.name }}" style="display: inline-block; padding: 6px 12px; background: {{ facet.color }}20; color: {{ facet.color }}; border: 1px solid {{ facet.color }}; border-radius: 15px; text-decoration: none; font-size: 14px; font-weight: 600; transition: all 0.2s;">
|
||||||
{{ tag.name }}
|
{{ tag.name }}
|
||||||
<span style="background: {{ facet.color }}; color: white; padding: 1px 8px; border-radius: 10px; margin-left: 6px; font-size: 12px;">{{ count }}</span>
|
<span style="background: {{ facet.color }}; color: white; padding: 1px 8px; border-radius: 10px; margin-left: 6px; font-size: 12px;">{{ count }}</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -79,6 +82,97 @@
|
|||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
const resultsContainer = document.getElementById('results-container');
|
||||||
|
const resultsBody = document.getElementById('results-body');
|
||||||
|
const noResults = document.getElementById('no-results');
|
||||||
|
|
||||||
|
let searchTimeout = null;
|
||||||
|
|
||||||
|
function performSearch(query) {
|
||||||
|
if (query.length < 2) {
|
||||||
|
resultsContainer.style.display = 'none';
|
||||||
|
noResults.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchInput.style.borderColor = '#667eea';
|
||||||
|
searchInput.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
|
||||||
|
searchTimeout = setTimeout(function() {
|
||||||
|
fetch('/search/api/?q=' + encodeURIComponent(query))
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
resultsBody.innerHTML = '';
|
||||||
|
|
||||||
|
if (data.results.length === 0) {
|
||||||
|
resultsContainer.style.display = 'none';
|
||||||
|
noResults.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
noResults.style.display = 'none';
|
||||||
|
resultsContainer.style.display = 'block';
|
||||||
|
|
||||||
|
data.results.forEach(function(thing) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.style.borderBottom = '1px solid #e0e0e0';
|
||||||
|
row.style.transition = 'background 0.2s';
|
||||||
|
|
||||||
|
let tagsHtml = thing.tags.length > 0
|
||||||
|
? thing.tags.map(tag =>
|
||||||
|
'<span style="display: inline-block; padding: 3px 8px; margin: 2px; border-radius: 12px; font-size: 11px; background: ' + escapeHtml(tag.color) + '20; color: ' + escapeHtml(tag.color) + '; border: 1px solid ' + escapeHtml(tag.color) + '40;">' + escapeHtml(tag.name) + '</span>'
|
||||||
|
).join('')
|
||||||
|
: '<span style="color: #999; font-style: italic; font-size: 13px;">-</span>';
|
||||||
|
|
||||||
|
row.innerHTML =
|
||||||
|
'<td style="padding: 15px 20px;"><a href="/thing/' + thing.id + '/">' + escapeHtml(thing.name) + '</a></td>' +
|
||||||
|
'<td style="padding: 15px 20px;">' + tagsHtml + '</td>' +
|
||||||
|
'<td style="padding: 15px 20px;"><a href="/box/' + escapeHtml(thing.box) + '/">' + escapeHtml(thing.box) + '</a></td>' +
|
||||||
|
'<td style="padding: 15px 20px; color: #777;" class="description">' + escapeHtml(thing.description) + '</td>';
|
||||||
|
|
||||||
|
row.addEventListener('mouseenter', function() {
|
||||||
|
this.style.background = '#f8f9fa';
|
||||||
|
});
|
||||||
|
row.addEventListener('mouseleave', function() {
|
||||||
|
this.style.background = 'white';
|
||||||
|
});
|
||||||
|
|
||||||
|
resultsBody.appendChild(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', function() {
|
||||||
|
const query = this.value.trim();
|
||||||
|
|
||||||
|
if (searchTimeout) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
performSearch(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('blur', function() {
|
||||||
|
searchInput.style.borderColor = '#e0e0e0';
|
||||||
|
searchInput.style.boxShadow = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const initialQuery = urlParams.get('q');
|
||||||
|
|
||||||
|
if (initialQuery) {
|
||||||
|
searchInput.value = initialQuery;
|
||||||
|
performSearch(initialQuery.trim());
|
||||||
|
}
|
||||||
|
|
||||||
$('.facet-header').click(function() {
|
$('.facet-header').click(function() {
|
||||||
const $content = $(this).next('.facet-tags');
|
const $content = $(this).next('.facet-tags');
|
||||||
const $icon = $(this).find('.facet-toggle');
|
const $icon = $(this).find('.facet-toggle');
|
||||||
@@ -99,17 +193,6 @@ $(document).ready(function() {
|
|||||||
$(this).css('transform', 'scale(1)');
|
$(this).css('transform', 'scale(1)');
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
$('.box-card').hover(
|
|
||||||
function() {
|
|
||||||
$(this).css('transform', 'translateY(-5px)');
|
|
||||||
$(this).css('box-shadow', '0 12px 24px rgba(102, 126, 234, 0.2)');
|
|
||||||
},
|
|
||||||
function() {
|
|
||||||
$(this).css('transform', 'translateY(0)');
|
|
||||||
$(this).css('box-shadow', 'none');
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
41
boxes/templates/boxes/resources_list.html
Normal file
41
boxes/templates/boxes/resources_list.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Resources - LabHelper{% endblock %}
|
||||||
|
|
||||||
|
{% block page_header %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1><i class="fas fa-folder-open"></i> Resources</h1>
|
||||||
|
<p class="breadcrumb">All links and files from things</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="section">
|
||||||
|
<h2><i class="fas fa-list"></i> All Resources</h2>
|
||||||
|
{% if resources %}
|
||||||
|
<ul style="list-style: none; padding: 0;">
|
||||||
|
{% for resource in resources %}
|
||||||
|
<li style="padding: 8px 0; border-bottom: 1px solid #eee;">
|
||||||
|
<a href="{% url 'thing_detail' resource.thing_id %}" style="display: inline-block; width: 20px; text-align: center; color: #667eea; text-decoration: none;">
|
||||||
|
{% if resource.type == 'link' %}
|
||||||
|
<i class="fas fa-link"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="fas fa-file"></i>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
<strong>{{ resource.thing_name }}</strong>:
|
||||||
|
{% if resource.type == 'link' %}
|
||||||
|
<a href="{{ resource.url }}" target="_blank" rel="noopener noreferrer">{{ resource.title }}</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ resource.url }}">{{ resource.title }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: #888;">No resources found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -5,13 +5,18 @@
|
|||||||
{% block title %}{{ thing.name }} - LabHelper{% endblock %}
|
{% block title %}{{ thing.name }} - LabHelper{% endblock %}
|
||||||
|
|
||||||
{% block page_header %}
|
{% block page_header %}
|
||||||
<div class="page-header">
|
<div class="page-header" style="display: flex; justify-content: space-between; align-items: start;">
|
||||||
<h1><i class="fas fa-cube"></i> {{ thing.name }}</h1>
|
<div>
|
||||||
<p class="breadcrumb">
|
<h1><i class="fas fa-cube"></i> {{ thing.name }}</h1>
|
||||||
<a href="/"><i class="fas fa-home"></i> Home</a> /
|
<p class="breadcrumb">
|
||||||
<a href="/box/{{ thing.box.id }}/"><i class="fas fa-box"></i> Box {{ thing.box.id }}</a> /
|
<a href="/"><i class="fas fa-home"></i> Home</a> /
|
||||||
{{ thing.name }}
|
<a href="/box/{{ thing.box.id }}/"><i class="fas fa-box"></i> Box {{ thing.box.id }}</a> /
|
||||||
</p>
|
{{ thing.name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'edit_thing' thing.id %}" class="btn" style="margin-top: 10px;">
|
||||||
|
<i class="fas fa-edit"></i> Edit
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -31,27 +36,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" enctype="multipart/form-data" style="margin-top: 20px;">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="upload_picture">
|
|
||||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
|
||||||
<label class="btn" style="cursor: pointer; display: inline-flex; align-items: center; gap: 8px;">
|
|
||||||
<i class="fas fa-camera"></i>
|
|
||||||
<span>{% if thing.picture %}Change picture{% else %}Add picture{% endif %}</span>
|
|
||||||
<input type="file" id="picture_upload" name="picture" accept="image/*" style="display: none;" onchange="this.form.submit();">
|
|
||||||
</label>
|
|
||||||
{% if thing.picture %}
|
|
||||||
<button type="submit" name="action" value="delete_picture" class="btn" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); border: none;" onclick="return confirm('Are you sure you want to delete this picture?');">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
<span>Remove</span>
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="thing-details" style="flex-grow: 1; min-width: 300px;">
|
<div class="thing-details" style="flex-grow: 1; min-width: 300px;">
|
||||||
|
{% if thing.tags.all %}
|
||||||
<div class="detail-row" style="margin-bottom: 25px;">
|
<div class="detail-row" style="margin-bottom: 25px;">
|
||||||
<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-tags"></i> Tags
|
<i class="fas fa-tags"></i> Tags
|
||||||
@@ -65,21 +53,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
|
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
|
||||||
{% for tag in facet.list %}
|
{% for tag in facet.list %}
|
||||||
<form method="post" style="display: inline;" onsubmit="return confirm('Remove tag {{ tag.facet.name }}:{{ tag.name }}?');">
|
<span style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: {{ facet.grouper.color }}20; color: {{ facet.grouper.color }}; border: 2px solid {{ facet.grouper.color }}; border-radius: 20px; font-size: 14px; font-weight: 600;">
|
||||||
{% csrf_token %}
|
{{ tag.name }}
|
||||||
<input type="hidden" name="action" value="remove_tag">
|
</span>
|
||||||
<input type="hidden" name="tag_id" value="{{ tag.id }}">
|
|
||||||
<button type="submit" style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: {{ facet.grouper.color }}20; color: {{ facet.grouper.color }}; border: 2px solid {{ facet.grouper.color }}; border-radius: 20px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.3s;">
|
|
||||||
{{ tag.name }}
|
|
||||||
<i class="fas fa-times" style="font-size: 12px;"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="detail-row" style="margin-bottom: 25px;">
|
<div class="detail-row" style="margin-bottom: 25px;">
|
||||||
<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;">
|
||||||
@@ -109,20 +92,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="display: flex; flex-direction: column; gap: 10px;">
|
<div style="display: flex; flex-direction: column; gap: 10px;">
|
||||||
{% for file in thing.files.all %}
|
{% for file in thing.files.all %}
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
|
<div style="display: flex; align-items: center; gap: 10px; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
|
||||||
<div style="display: flex; align-items: center; gap: 10px;">
|
<i class="fas fa-paperclip" style="color: #667eea; font-size: 16px;"></i>
|
||||||
<i class="fas fa-paperclip" style="color: #667eea; font-size: 16px;"></i>
|
<a href="{{ file.file.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ file.title }}</a>
|
||||||
<a href="{{ file.file.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ file.title }}</a>
|
<span style="color: #999; font-size: 12px;">({{ file.filename }})</span>
|
||||||
<span style="color: #999; font-size: 12px;">({{ file.filename }})</span>
|
|
||||||
</div>
|
|
||||||
<form method="post" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this file?');">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="delete_file">
|
|
||||||
<input type="hidden" name="file_id" value="{{ file.id }}">
|
|
||||||
<button type="submit" style="background: none; border: none; cursor: pointer; color: #e74c3c; padding: 5px; transition: all 0.3s;" onmouseover="this.style.color='#c0392b'" onmouseout="this.style.color='#e74c3c'">
|
|
||||||
<i class="fas fa-times" style="font-size: 14px;"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -136,142 +109,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="display: flex; flex-direction: column; gap: 10px;">
|
<div style="display: flex; flex-direction: column; gap: 10px;">
|
||||||
{% for link in thing.links.all %}
|
{% for link in thing.links.all %}
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
|
<div style="display: flex; align-items: center; gap: 10px; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
|
||||||
<div style="display: flex; align-items: center; gap: 10px;">
|
<i class="fas fa-external-link-alt" style="color: #667eea; font-size: 16px;"></i>
|
||||||
<i class="fas fa-external-link-alt" style="color: #667eea; font-size: 16px;"></i>
|
<a href="{{ link.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ link.title }}</a>
|
||||||
<a href="{{ link.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ link.title }}</a>
|
|
||||||
</div>
|
|
||||||
<form method="post" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this link?');">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="delete_link">
|
|
||||||
<input type="hidden" name="link_id" value="{{ link.id }}">
|
|
||||||
<button type="submit" style="background: none; border: none; cursor: pointer; color: #e74c3c; padding: 5px; transition: all 0.3s;" onmouseover="this.style.color='#c0392b'" onmouseout="this.style.color='#e74c3c'">
|
|
||||||
<i class="fas fa-times" style="font-size: 14px;"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2 style="color: #667eea; font-size: 20px; font-weight: 700; margin-top: 0; margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
|
|
||||||
<i class="fas fa-plus-circle"></i> Add Tags
|
|
||||||
</h2>
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px,1fr)); gap: 30px;">
|
|
||||||
<div>
|
|
||||||
<h3 style="margin: 0 0 15px 0; color: #667eea; font-size: 16px; font-weight: 600;">
|
|
||||||
<i class="fas fa-tag"></i> Add Tag
|
|
||||||
</h3>
|
|
||||||
<form method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="add_tag">
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 15px;">
|
|
||||||
<div>
|
|
||||||
<label for="tag_select" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">Select Tag</label>
|
|
||||||
<select name="tag_id" id="tag_select" style="width: 100%; padding: 12px 16px; border: 2px solid #e0e0e0; border-radius: 10px; font-size: 15px; background: white; cursor: pointer; transition: all 0.3s;">
|
|
||||||
<option value="">-- Select a tag --</option>
|
|
||||||
{% for facet in facets %}
|
|
||||||
<optgroup label="{{ facet.name }} ({{ facet.get_cardinality_display }})">
|
|
||||||
{% for tag in facet.tags.all %}
|
|
||||||
{% if tag not in thing.tags.all %}
|
|
||||||
<option value="{{ tag.id }}">
|
|
||||||
{{ tag.name }}
|
|
||||||
</option>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</optgroup>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn">
|
|
||||||
<i class="fas fa-plus"></i> Add Tag
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2 style="color: #667eea; font-size: 20px; font-weight: 700; margin-top: 0; margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
|
|
||||||
<i class="fas fa-plus-circle"></i> Add Attachments
|
|
||||||
</h2>
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px,1fr)); gap: 30px;">
|
|
||||||
<div>
|
|
||||||
<h3 style="margin: 0 0 15px 0; color: #667eea; font-size: 16px; font-weight: 600;">
|
|
||||||
<i class="fas fa-file-upload"></i> Upload File
|
|
||||||
</h3>
|
|
||||||
<form method="post" enctype="multipart/form-data">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="add_file">
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 15px;">
|
|
||||||
<div>
|
|
||||||
<label for="file_title" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">Title</label>
|
|
||||||
<input type="text" id="file_title" name="title" style="width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="file_upload" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">File</label>
|
|
||||||
<input type="file" id="file_upload" name="file" style="width: 100%; padding: 10px 15px; border: 2px dashed #e0e0e0; border-radius: 8px; font-size: 14px; background: #f8f9fa;">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn">
|
|
||||||
<i class="fas fa-upload"></i> Upload File
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 style="margin: 0 0 15px 0; color: #667eea; font-size: 16px; font-weight: 600;">
|
|
||||||
<i class="fas fa-link"></i> Add Link
|
|
||||||
</h3>
|
|
||||||
<form method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="add_link">
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 15px;">
|
|
||||||
<div>
|
|
||||||
<label for="link_title" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">Title</label>
|
|
||||||
<input type="text" id="link_title" name="title" style="width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="link_url" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">URL</label>
|
|
||||||
<input type="url" id="link_url" name="url" style="width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn">
|
|
||||||
<i class="fas fa-plus"></i> Add Link
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="post" class="section">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="move">
|
|
||||||
<div style="display: flex; align-items: center; gap: 15px; flex-wrap: wrap;">
|
|
||||||
<div style="flex-grow: 1;">
|
|
||||||
<label for="new_box" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">
|
|
||||||
<i class="fas fa-exchange-alt"></i> Move to:
|
|
||||||
</label>
|
|
||||||
<select name="new_box" id="new_box" style="width: 100%; max-width: 400px; padding: 12px 16px; border: 2px solid #e0e0e0; border-radius: 10px; font-size: 15px; background: white; cursor: pointer; transition: all 0.3s;">
|
|
||||||
<option value="">Select a box...</option>
|
|
||||||
{% for box in boxes %}
|
|
||||||
<option value="{{ box.id }}" {% if box.id == thing.box.id %}selected{% endif %}>
|
|
||||||
Box {{ box.id }} ({{ box.box_type.name }})
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn" style="height: 48px; min-width: 120px; margin-top: 24px;">
|
|
||||||
<i class="fas fa-arrows-alt"></i> Move
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
@@ -352,15 +200,3 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
$('#new_box').on('focus', function() {
|
|
||||||
$(this).css('border-color', '#667eea');
|
|
||||||
$(this).css('box-shadow', '0 0 0 3px rgba(102, 126, 234, 0.1)');
|
|
||||||
}).on('blur', function() {
|
|
||||||
$(this).css('border-color', '#e0e0e0');
|
|
||||||
$(this).css('box-shadow', 'none');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
148
boxes/tests.py
148
boxes/tests.py
@@ -271,6 +271,11 @@ class IndexViewTests(AuthTestCase):
|
|||||||
response = self.client.get('/')
|
response = self.client.get('/')
|
||||||
self.assertContains(response, '/admin/')
|
self.assertContains(response, '/admin/')
|
||||||
|
|
||||||
|
def test_index_contains_search_input(self):
|
||||||
|
"""Index page should contain search input."""
|
||||||
|
response = self.client.get('/')
|
||||||
|
self.assertContains(response, 'id="search-input"')
|
||||||
|
|
||||||
def test_index_requires_login(self):
|
def test_index_requires_login(self):
|
||||||
"""Index page should redirect to login if not authenticated."""
|
"""Index page should redirect to login if not authenticated."""
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
@@ -441,9 +446,16 @@ class ThingDetailViewTests(AuthTestCase):
|
|||||||
self.assertContains(response, 'Arduino Uno')
|
self.assertContains(response, 'Arduino Uno')
|
||||||
|
|
||||||
def test_thing_detail_shows_tags_section(self):
|
def test_thing_detail_shows_tags_section(self):
|
||||||
"""Thing detail page should show tags section."""
|
"""Thing detail page should show tags section when tags exist."""
|
||||||
|
facet = Facet.objects.create(
|
||||||
|
name='Electronics',
|
||||||
|
color='#FF5733',
|
||||||
|
cardinality=Facet.Cardinality.MULTIPLE
|
||||||
|
)
|
||||||
|
tag = Tag.objects.create(facet=facet, name='Arduino')
|
||||||
|
self.thing.tags.add(tag)
|
||||||
response = self.client.get(f'/thing/{self.thing.id}/')
|
response = self.client.get(f'/thing/{self.thing.id}/')
|
||||||
self.assertContains(response, 'Tags')
|
self.assertContains(response, 'Electronics')
|
||||||
|
|
||||||
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."""
|
||||||
@@ -472,47 +484,28 @@ class ThingDetailViewTests(AuthTestCase):
|
|||||||
response = self.client.get(f'/thing/{self.thing.id}/')
|
response = self.client.get(f'/thing/{self.thing.id}/')
|
||||||
self.assertRedirects(response, f'/login/?next=/thing/{self.thing.id}/')
|
self.assertRedirects(response, f'/login/?next=/thing/{self.thing.id}/')
|
||||||
|
|
||||||
def test_thing_detail_move_to_box(self):
|
def test_thing_detail_has_edit_button(self):
|
||||||
"""Thing can be moved to another box via POST."""
|
"""Thing detail page should have an edit button."""
|
||||||
new_box = Box.objects.create(id='BOX002', box_type=self.box_type)
|
|
||||||
response = self.client.post(
|
|
||||||
f'/thing/{self.thing.id}/',
|
|
||||||
{'action': 'move', 'new_box': 'BOX002'}
|
|
||||||
)
|
|
||||||
self.assertRedirects(response, f'/thing/{self.thing.id}/')
|
|
||||||
self.thing.refresh_from_db()
|
|
||||||
self.assertEqual(self.thing.box, new_box)
|
|
||||||
|
|
||||||
def test_thing_detail_move_shows_all_boxes(self):
|
|
||||||
"""Thing detail page should show all available boxes in dropdown."""
|
|
||||||
Box.objects.create(id='BOX002', box_type=self.box_type)
|
|
||||||
Box.objects.create(id='BOX003', box_type=self.box_type)
|
|
||||||
response = self.client.get(f'/thing/{self.thing.id}/')
|
response = self.client.get(f'/thing/{self.thing.id}/')
|
||||||
self.assertContains(response, 'BOX001')
|
self.assertContains(response, 'Edit')
|
||||||
self.assertContains(response, 'BOX002')
|
self.assertContains(response, f'/thing/{self.thing.id}/edit/')
|
||||||
self.assertContains(response, 'BOX003')
|
|
||||||
|
|
||||||
|
|
||||||
class SearchViewTests(AuthTestCase):
|
class BoxesListViewTests(AuthTestCase):
|
||||||
"""Tests for search view."""
|
"""Tests for boxes list view."""
|
||||||
|
|
||||||
def test_search_returns_200(self):
|
def test_boxes_list_returns_200(self):
|
||||||
"""Search page should return 200 status."""
|
"""Boxes list page should return 200 status."""
|
||||||
response = self.client.get('/search/')
|
response = self.client.get('/search/')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_search_contains_search_input(self):
|
def test_boxes_list_contains_boxes_header(self):
|
||||||
"""Search page should contain search input field."""
|
"""Boxes list page should contain boxes header."""
|
||||||
response = self.client.get('/search/')
|
response = self.client.get('/search/')
|
||||||
self.assertContains(response, 'id="search-input"')
|
self.assertContains(response, 'Boxes')
|
||||||
|
|
||||||
def test_search_contains_results_container(self):
|
def test_boxes_list_requires_login(self):
|
||||||
"""Search page should contain results table."""
|
"""Boxes list page should redirect to login if not authenticated."""
|
||||||
response = self.client.get('/search/')
|
|
||||||
self.assertContains(response, 'id="results-container"')
|
|
||||||
|
|
||||||
def test_search_requires_login(self):
|
|
||||||
"""Search page should redirect to login if not authenticated."""
|
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
response = self.client.get('/search/')
|
response = self.client.get('/search/')
|
||||||
self.assertRedirects(response, '/login/?next=/search/')
|
self.assertRedirects(response, '/login/?next=/search/')
|
||||||
@@ -1024,14 +1017,14 @@ class ThingPictureUploadTests(AuthTestCase):
|
|||||||
b'\x05\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82'
|
b'\x05\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82'
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_thing_detail_shows_add_picture_button(self):
|
def test_edit_thing_shows_add_picture_button(self):
|
||||||
"""Thing detail page should show 'Add picture' button when thing has no picture."""
|
"""Edit thing page should show 'Add picture' button when thing has no picture."""
|
||||||
response = self.client.get(f'/thing/{self.thing.id}/')
|
response = self.client.get(f'/thing/{self.thing.id}/edit/')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'Add picture')
|
self.assertContains(response, 'Add picture')
|
||||||
|
|
||||||
def test_thing_detail_shows_change_picture_button(self):
|
def test_edit_thing_shows_change_picture_button(self):
|
||||||
"""Thing detail page should show 'Change picture' button when thing has a picture."""
|
"""Edit thing page should show 'Change picture' button when thing has a picture."""
|
||||||
image = SimpleUploadedFile(
|
image = SimpleUploadedFile(
|
||||||
name='test.png',
|
name='test.png',
|
||||||
content=self.image_data,
|
content=self.image_data,
|
||||||
@@ -1039,14 +1032,14 @@ class ThingPictureUploadTests(AuthTestCase):
|
|||||||
)
|
)
|
||||||
self.thing.picture = image
|
self.thing.picture = image
|
||||||
self.thing.save()
|
self.thing.save()
|
||||||
response = self.client.get(f'/thing/{self.thing.id}/')
|
response = self.client.get(f'/thing/{self.thing.id}/edit/')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'Change picture')
|
self.assertContains(response, 'Change picture')
|
||||||
# Clean up
|
# Clean up
|
||||||
self.thing.picture.delete(save=False)
|
self.thing.picture.delete(save=False)
|
||||||
|
|
||||||
def test_thing_detail_shows_remove_button(self):
|
def test_edit_thing_shows_remove_button(self):
|
||||||
"""Thing detail page should show 'Remove' button when thing has a picture."""
|
"""Edit thing page should show 'Remove' button when thing has a picture."""
|
||||||
image = SimpleUploadedFile(
|
image = SimpleUploadedFile(
|
||||||
name='test.png',
|
name='test.png',
|
||||||
content=self.image_data,
|
content=self.image_data,
|
||||||
@@ -1054,14 +1047,14 @@ class ThingPictureUploadTests(AuthTestCase):
|
|||||||
)
|
)
|
||||||
self.thing.picture = image
|
self.thing.picture = image
|
||||||
self.thing.save()
|
self.thing.save()
|
||||||
response = self.client.get(f'/thing/{self.thing.id}/')
|
response = self.client.get(f'/thing/{self.thing.id}/edit/')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'Remove')
|
self.assertContains(response, 'Remove')
|
||||||
# Clean up
|
# Clean up
|
||||||
self.thing.picture.delete(save=False)
|
self.thing.picture.delete(save=False)
|
||||||
|
|
||||||
def test_delete_picture_removes_picture(self):
|
def test_delete_picture_removes_picture(self):
|
||||||
"""Deleting a picture should remove it from the thing."""
|
"""Deleting a picture should remove it from thing."""
|
||||||
image = SimpleUploadedFile(
|
image = SimpleUploadedFile(
|
||||||
name='test.png',
|
name='test.png',
|
||||||
content=self.image_data,
|
content=self.image_data,
|
||||||
@@ -1070,19 +1063,28 @@ class ThingPictureUploadTests(AuthTestCase):
|
|||||||
self.thing.picture = image
|
self.thing.picture = image
|
||||||
self.thing.save()
|
self.thing.save()
|
||||||
|
|
||||||
response = self.client.post(f'/thing/{self.thing.id}/', {
|
response = self.client.post(f'/thing/{self.thing.id}/edit/', {
|
||||||
'action': 'delete_picture'
|
'action': 'delete_picture'
|
||||||
})
|
})
|
||||||
self.assertRedirects(response, f'/thing/{self.thing.id}/')
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
||||||
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):
|
def test_delete_picture_on_thing_without_picture(self):
|
||||||
"""Deleting a picture from a thing without a picture should succeed."""
|
"""Deleting a picture from a thing without a picture should succeed."""
|
||||||
response = self.client.post(f'/thing/{self.thing.id}/', {
|
response = self.client.post(f'/thing/{self.thing.id}/edit/', {
|
||||||
'action': 'delete_picture'
|
'action': 'delete_picture'
|
||||||
})
|
})
|
||||||
self.assertRedirects(response, f'/thing/{self.thing.id}/')
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
||||||
|
self.thing.refresh_from_db()
|
||||||
|
self.assertFalse(self.thing.picture.name)
|
||||||
|
|
||||||
|
def test_delete_picture_on_thing_without_picture(self):
|
||||||
|
"""Deleting a picture from a thing without a picture should succeed."""
|
||||||
|
response = self.client.post(f'/thing/{self.thing.id}/edit/', {
|
||||||
|
'action': 'delete_picture'
|
||||||
|
})
|
||||||
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
||||||
self.thing.refresh_from_db()
|
self.thing.refresh_from_db()
|
||||||
self.assertFalse(self.thing.picture.name)
|
self.assertFalse(self.thing.picture.name)
|
||||||
|
|
||||||
@@ -1257,24 +1259,24 @@ class ThingFileAndLinkCRUDTests(AuthTestCase):
|
|||||||
content_type='application/pdf'
|
content_type='application/pdf'
|
||||||
)
|
)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f'/thing/{self.thing.id}/',
|
f'/thing/{self.thing.id}/edit/',
|
||||||
{'action': 'add_file', 'title': 'Datasheet', 'file': uploaded_file}
|
{'action': 'add_file', 'title': 'Datasheet', 'file': uploaded_file}
|
||||||
)
|
)
|
||||||
self.assertRedirects(response, f'/thing/{self.thing.id}/')
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
||||||
self.assertEqual(self.thing.files.count(), 1)
|
self.assertEqual(self.thing.files.count(), 1)
|
||||||
self.assertEqual(self.thing.files.first().title, 'Datasheet')
|
self.assertEqual(self.thing.files.first().title, 'Datasheet')
|
||||||
|
|
||||||
def test_add_link_to_thing(self):
|
def test_add_link_to_thing(self):
|
||||||
"""Adding a link should create ThingLink."""
|
"""Adding a link should create ThingLink."""
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f'/thing/{self.thing.id}/',
|
f'/thing/{self.thing.id}/edit/',
|
||||||
{
|
{
|
||||||
'action': 'add_link',
|
'action': 'add_link',
|
||||||
'title': 'Manufacturer',
|
'title': 'Manufacturer',
|
||||||
'url': 'https://www.arduino.cc'
|
'url': 'https://www.arduino.cc'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.assertRedirects(response, f'/thing/{self.thing.id}/')
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
||||||
self.assertEqual(self.thing.links.count(), 1)
|
self.assertEqual(self.thing.links.count(), 1)
|
||||||
self.assertEqual(self.thing.links.first().title, 'Manufacturer')
|
self.assertEqual(self.thing.links.first().title, 'Manufacturer')
|
||||||
self.assertEqual(self.thing.links.first().url, 'https://www.arduino.cc')
|
self.assertEqual(self.thing.links.first().url, 'https://www.arduino.cc')
|
||||||
@@ -1288,10 +1290,10 @@ class ThingFileAndLinkCRUDTests(AuthTestCase):
|
|||||||
)
|
)
|
||||||
file_id = thing_file.id
|
file_id = thing_file.id
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f'/thing/{self.thing.id}/',
|
f'/thing/{self.thing.id}/edit/',
|
||||||
{'action': 'delete_file', 'file_id': str(file_id)}
|
{'action': 'delete_file', 'file_id': str(file_id)}
|
||||||
)
|
)
|
||||||
self.assertRedirects(response, f'/thing/{self.thing.id}/')
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
||||||
self.assertFalse(ThingFile.objects.filter(id=file_id).exists())
|
self.assertFalse(ThingFile.objects.filter(id=file_id).exists())
|
||||||
|
|
||||||
def test_delete_link_from_thing(self):
|
def test_delete_link_from_thing(self):
|
||||||
@@ -1303,10 +1305,10 @@ class ThingFileAndLinkCRUDTests(AuthTestCase):
|
|||||||
)
|
)
|
||||||
link_id = thing_link.id
|
link_id = thing_link.id
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f'/thing/{self.thing.id}/',
|
f'/thing/{self.thing.id}/edit/',
|
||||||
{'action': 'delete_link', 'link_id': str(link_id)}
|
{'action': 'delete_link', 'link_id': str(link_id)}
|
||||||
)
|
)
|
||||||
self.assertRedirects(response, f'/thing/{self.thing.id}/')
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
||||||
self.assertFalse(ThingLink.objects.filter(id=link_id).exists())
|
self.assertFalse(ThingLink.objects.filter(id=link_id).exists())
|
||||||
|
|
||||||
def test_cannot_delete_file_from_other_thing(self):
|
def test_cannot_delete_file_from_other_thing(self):
|
||||||
@@ -1322,10 +1324,10 @@ class ThingFileAndLinkCRUDTests(AuthTestCase):
|
|||||||
)
|
)
|
||||||
file_id = thing_file.id
|
file_id = thing_file.id
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f'/thing/{self.thing.id}/',
|
f'/thing/{self.thing.id}/edit/',
|
||||||
{'action': 'delete_file', 'file_id': str(file_id)}
|
{'action': 'delete_file', 'file_id': str(file_id)}
|
||||||
)
|
)
|
||||||
self.assertRedirects(response, f'/thing/{self.thing.id}/')
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
||||||
self.assertTrue(ThingFile.objects.filter(id=file_id).exists())
|
self.assertTrue(ThingFile.objects.filter(id=file_id).exists())
|
||||||
|
|
||||||
def test_cannot_delete_link_from_other_thing(self):
|
def test_cannot_delete_link_from_other_thing(self):
|
||||||
@@ -1341,10 +1343,10 @@ class ThingFileAndLinkCRUDTests(AuthTestCase):
|
|||||||
)
|
)
|
||||||
link_id = thing_link.id
|
link_id = thing_link.id
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f'/thing/{self.thing.id}/',
|
f'/thing/{self.thing.id}/edit/',
|
||||||
{'action': 'delete_link', 'link_id': str(link_id)}
|
{'action': 'delete_link', 'link_id': str(link_id)}
|
||||||
)
|
)
|
||||||
self.assertRedirects(response, f'/thing/{self.thing.id}/')
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
||||||
self.assertTrue(ThingLink.objects.filter(id=link_id).exists())
|
self.assertTrue(ThingLink.objects.filter(id=link_id).exists())
|
||||||
|
|
||||||
def test_thing_detail_shows_files_section(self):
|
def test_thing_detail_shows_files_section(self):
|
||||||
@@ -1371,9 +1373,9 @@ class ThingFileAndLinkCRUDTests(AuthTestCase):
|
|||||||
self.assertContains(response, 'Links')
|
self.assertContains(response, 'Links')
|
||||||
self.assertContains(response, 'Documentation')
|
self.assertContains(response, 'Documentation')
|
||||||
|
|
||||||
def test_thing_detail_shows_upload_forms(self):
|
def test_edit_thing_shows_upload_forms(self):
|
||||||
"""Thing detail page should show upload forms."""
|
"""Edit thing page should show upload forms."""
|
||||||
response = self.client.get(f'/thing/{self.thing.id}/')
|
response = self.client.get(f'/thing/{self.thing.id}/edit/')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'Upload File')
|
self.assertContains(response, 'Upload File')
|
||||||
self.assertContains(response, 'Add Link')
|
self.assertContains(response, 'Add Link')
|
||||||
@@ -1630,29 +1632,29 @@ class ThingTagTests(AuthTestCase):
|
|||||||
self.assertNotIn(self.tag, self.thing.tags.all())
|
self.assertNotIn(self.tag, self.thing.tags.all())
|
||||||
|
|
||||||
def test_thing_detail_add_tag(self):
|
def test_thing_detail_add_tag(self):
|
||||||
"""Thing detail page can add a tag via POST."""
|
"""Edit thing page can add a tag via POST."""
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f'/thing/{self.thing.id}/',
|
f'/thing/{self.thing.id}/edit/',
|
||||||
{'action': 'add_tag', 'tag_id': str(self.tag.id)}
|
{'action': 'add_tag', 'tag_id': str(self.tag.id)}
|
||||||
)
|
)
|
||||||
self.assertRedirects(response, f'/thing/{self.thing.id}/')
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
||||||
self.thing.refresh_from_db()
|
self.thing.refresh_from_db()
|
||||||
self.assertIn(self.tag, self.thing.tags.all())
|
self.assertIn(self.tag, self.thing.tags.all())
|
||||||
|
|
||||||
def test_thing_detail_remove_tag(self):
|
def test_thing_detail_remove_tag(self):
|
||||||
"""Thing detail page can remove a tag via POST."""
|
"""Edit thing page can remove a tag via POST."""
|
||||||
self.thing.tags.add(self.tag)
|
self.thing.tags.add(self.tag)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f'/thing/{self.thing.id}/',
|
f'/thing/{self.thing.id}/edit/',
|
||||||
{'action': 'remove_tag', 'tag_id': str(self.tag.id)}
|
{'action': 'remove_tag', 'tag_id': str(self.tag.id)}
|
||||||
)
|
)
|
||||||
self.assertRedirects(response, f'/thing/{self.thing.id}/')
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
||||||
self.thing.refresh_from_db()
|
self.thing.refresh_from_db()
|
||||||
self.assertNotIn(self.tag, self.thing.tags.all())
|
self.assertNotIn(self.tag, self.thing.tags.all())
|
||||||
|
|
||||||
def test_thing_detail_shows_available_tags(self):
|
def test_thing_detail_shows_available_tags(self):
|
||||||
"""Thing detail page should show available tags to add."""
|
"""Edit thing page should show available tags to add."""
|
||||||
response = self.client.get(f'/thing/{self.thing.id}/')
|
response = self.client.get(f'/thing/{self.thing.id}/edit/')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'Electronics')
|
self.assertContains(response, 'Electronics')
|
||||||
|
|
||||||
@@ -1674,10 +1676,10 @@ class ThingTagTests(AuthTestCase):
|
|||||||
|
|
||||||
self.thing.tags.add(tag_low)
|
self.thing.tags.add(tag_low)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f'/thing/{self.thing.id}/',
|
f'/thing/{self.thing.id}/edit/',
|
||||||
{'action': 'add_tag', 'tag_id': str(tag_high.id)}
|
{'action': 'add_tag', 'tag_id': str(tag_high.id)}
|
||||||
)
|
)
|
||||||
self.assertRedirects(response, f'/thing/{self.thing.id}/')
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
||||||
self.thing.refresh_from_db()
|
self.thing.refresh_from_db()
|
||||||
self.assertNotIn(tag_low, self.thing.tags.all())
|
self.assertNotIn(tag_low, self.thing.tags.all())
|
||||||
self.assertIn(tag_high, self.thing.tags.all())
|
self.assertIn(tag_high, self.thing.tags.all())
|
||||||
|
|||||||
137
boxes/views.py
137
boxes/views.py
@@ -2,7 +2,7 @@ import bleach
|
|||||||
import markdown
|
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, Prefetch
|
||||||
from django.http import HttpResponse, JsonResponse
|
from django.http import HttpResponse, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ from .forms import (
|
|||||||
BoxForm,
|
BoxForm,
|
||||||
BoxTypeForm,
|
BoxTypeForm,
|
||||||
ThingFileForm,
|
ThingFileForm,
|
||||||
|
ThingForm,
|
||||||
ThingFormSet,
|
ThingFormSet,
|
||||||
ThingLinkForm,
|
ThingLinkForm,
|
||||||
ThingPictureForm,
|
ThingPictureForm,
|
||||||
@@ -31,8 +32,7 @@ def _strip_markdown(text, max_length=100):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def index(request):
|
def index(request):
|
||||||
"""Home page with boxes and tags."""
|
"""Home page with search and tags."""
|
||||||
boxes = Box.objects.select_related('box_type').all().order_by('id')
|
|
||||||
facets = Facet.objects.all().prefetch_related('tags')
|
facets = Facet.objects.all().prefetch_related('tags')
|
||||||
|
|
||||||
facet_tag_counts = {}
|
facet_tag_counts = {}
|
||||||
@@ -45,7 +45,6 @@ def index(request):
|
|||||||
facet_tag_counts[facet].append((tag, count))
|
facet_tag_counts[facet].append((tag, count))
|
||||||
|
|
||||||
return render(request, 'boxes/index.html', {
|
return render(request, 'boxes/index.html', {
|
||||||
'boxes': boxes,
|
|
||||||
'facets': facets,
|
'facets': facets,
|
||||||
'facet_tag_counts': facet_tag_counts,
|
'facet_tag_counts': facet_tag_counts,
|
||||||
})
|
})
|
||||||
@@ -64,7 +63,17 @@ def box_detail(request, box_id):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def thing_detail(request, thing_id):
|
def thing_detail(request, thing_id):
|
||||||
"""Display details of a thing."""
|
"""Display details of a thing (read-only)."""
|
||||||
|
thing = get_object_or_404(
|
||||||
|
Thing.objects.select_related('box', 'box__box_type').prefetch_related('files', 'links', 'tags'),
|
||||||
|
pk=thing_id
|
||||||
|
)
|
||||||
|
return render(request, 'boxes/thing_detail.html', {'thing': thing})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def edit_thing(request, thing_id):
|
||||||
|
"""Edit a thing's details."""
|
||||||
thing = get_object_or_404(
|
thing = get_object_or_404(
|
||||||
Thing.objects.select_related('box', 'box__box_type').prefetch_related('files', 'links', 'tags'),
|
Thing.objects.select_related('box', 'box__box_type').prefetch_related('files', 'links', 'tags'),
|
||||||
pk=thing_id
|
pk=thing_id
|
||||||
@@ -79,26 +88,32 @@ def thing_detail(request, thing_id):
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
action = request.POST.get('action')
|
action = request.POST.get('action')
|
||||||
|
|
||||||
if action == 'move':
|
if action == 'save_details':
|
||||||
|
form = ThingForm(request.POST, request.FILES, instance=thing)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
return redirect('thing_detail', thing_id=thing.id)
|
||||||
|
|
||||||
|
elif action == 'move':
|
||||||
new_box_id = request.POST.get('new_box')
|
new_box_id = request.POST.get('new_box')
|
||||||
if new_box_id:
|
if new_box_id:
|
||||||
new_box = get_object_or_404(Box, pk=new_box_id)
|
new_box = get_object_or_404(Box, pk=new_box_id)
|
||||||
thing.box = new_box
|
thing.box = new_box
|
||||||
thing.save()
|
thing.save()
|
||||||
return redirect('thing_detail', thing_id=thing.id)
|
return redirect('edit_thing', thing_id=thing.id)
|
||||||
|
|
||||||
elif action == 'upload_picture':
|
elif action == 'upload_picture':
|
||||||
picture_form = ThingPictureForm(request.POST, request.FILES, instance=thing)
|
picture_form = ThingPictureForm(request.POST, request.FILES, instance=thing)
|
||||||
if picture_form.is_valid():
|
if picture_form.is_valid():
|
||||||
picture_form.save()
|
picture_form.save()
|
||||||
return redirect('thing_detail', thing_id=thing.id)
|
return redirect('edit_thing', thing_id=thing.id)
|
||||||
|
|
||||||
elif action == 'delete_picture':
|
elif action == 'delete_picture':
|
||||||
if thing.picture:
|
if thing.picture:
|
||||||
thing.picture.delete()
|
thing.picture.delete()
|
||||||
thing.picture = None
|
thing.picture = None
|
||||||
thing.save()
|
thing.save()
|
||||||
return redirect('thing_detail', thing_id=thing.id)
|
return redirect('edit_thing', thing_id=thing.id)
|
||||||
|
|
||||||
elif action == 'add_file':
|
elif action == 'add_file':
|
||||||
file_form = ThingFileForm(request.POST, request.FILES)
|
file_form = ThingFileForm(request.POST, request.FILES)
|
||||||
@@ -106,7 +121,7 @@ def thing_detail(request, thing_id):
|
|||||||
thing_file = file_form.save(commit=False)
|
thing_file = file_form.save(commit=False)
|
||||||
thing_file.thing = thing
|
thing_file.thing = thing
|
||||||
thing_file.save()
|
thing_file.save()
|
||||||
return redirect('thing_detail', thing_id=thing.id)
|
return redirect('edit_thing', thing_id=thing.id)
|
||||||
|
|
||||||
elif action == 'add_link':
|
elif action == 'add_link':
|
||||||
link_form = ThingLinkForm(request.POST)
|
link_form = ThingLinkForm(request.POST)
|
||||||
@@ -114,7 +129,7 @@ def thing_detail(request, thing_id):
|
|||||||
thing_link = link_form.save(commit=False)
|
thing_link = link_form.save(commit=False)
|
||||||
thing_link.thing = thing
|
thing_link.thing = thing
|
||||||
thing_link.save()
|
thing_link.save()
|
||||||
return redirect('thing_detail', thing_id=thing.id)
|
return redirect('edit_thing', thing_id=thing.id)
|
||||||
|
|
||||||
elif action == 'delete_file':
|
elif action == 'delete_file':
|
||||||
file_id = request.POST.get('file_id')
|
file_id = request.POST.get('file_id')
|
||||||
@@ -125,7 +140,7 @@ def thing_detail(request, thing_id):
|
|||||||
thing_file.delete()
|
thing_file.delete()
|
||||||
except ThingFile.DoesNotExist:
|
except ThingFile.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
return redirect('thing_detail', thing_id=thing.id)
|
return redirect('edit_thing', thing_id=thing.id)
|
||||||
|
|
||||||
elif action == 'delete_link':
|
elif action == 'delete_link':
|
||||||
link_id = request.POST.get('link_id')
|
link_id = request.POST.get('link_id')
|
||||||
@@ -135,7 +150,7 @@ def thing_detail(request, thing_id):
|
|||||||
thing_link.delete()
|
thing_link.delete()
|
||||||
except ThingLink.DoesNotExist:
|
except ThingLink.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
return redirect('thing_detail', thing_id=thing.id)
|
return redirect('edit_thing', thing_id=thing.id)
|
||||||
|
|
||||||
elif action == 'add_tag':
|
elif action == 'add_tag':
|
||||||
tag_id = request.POST.get('tag_id')
|
tag_id = request.POST.get('tag_id')
|
||||||
@@ -149,7 +164,7 @@ def thing_detail(request, thing_id):
|
|||||||
thing.tags.add(tag)
|
thing.tags.add(tag)
|
||||||
except Tag.DoesNotExist:
|
except Tag.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
return redirect('thing_detail', thing_id=thing.id)
|
return redirect('edit_thing', thing_id=thing.id)
|
||||||
|
|
||||||
elif action == 'remove_tag':
|
elif action == 'remove_tag':
|
||||||
tag_id = request.POST.get('tag_id')
|
tag_id = request.POST.get('tag_id')
|
||||||
@@ -159,22 +174,28 @@ def thing_detail(request, thing_id):
|
|||||||
thing.tags.remove(tag)
|
thing.tags.remove(tag)
|
||||||
except Tag.DoesNotExist:
|
except Tag.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
return redirect('thing_detail', thing_id=thing.id)
|
return redirect('edit_thing', thing_id=thing.id)
|
||||||
|
|
||||||
return render(request, 'boxes/thing_detail.html', {
|
thing_form = ThingForm(instance=thing)
|
||||||
|
|
||||||
|
return render(request, 'boxes/edit_thing.html', {
|
||||||
'thing': thing,
|
'thing': thing,
|
||||||
'boxes': boxes,
|
'boxes': boxes,
|
||||||
'facets': facets,
|
'facets': facets,
|
||||||
'picture_form': picture_form,
|
'picture_form': picture_form,
|
||||||
'file_form': file_form,
|
'file_form': file_form,
|
||||||
'link_form': link_form,
|
'link_form': link_form,
|
||||||
|
'thing_form': thing_form,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def search(request):
|
def boxes_list(request):
|
||||||
"""Search page for things."""
|
"""Boxes list page showing all boxes with contents."""
|
||||||
return render(request, 'boxes/search.html')
|
boxes = Box.objects.select_related('box_type').prefetch_related('things').all().order_by('id')
|
||||||
|
return render(request, 'boxes/boxes_list.html', {
|
||||||
|
'boxes': boxes,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -350,3 +371,81 @@ def delete_box(request, box_id):
|
|||||||
return redirect('box_management')
|
return redirect('box_management')
|
||||||
box.delete()
|
box.delete()
|
||||||
return redirect('box_management')
|
return redirect('box_management')
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def resources_list(request):
|
||||||
|
"""List all links and files from things that have them."""
|
||||||
|
things_with_files = Thing.objects.filter(files__isnull=False).prefetch_related('files').distinct()
|
||||||
|
things_with_links = Thing.objects.filter(links__isnull=False).prefetch_related('links').distinct()
|
||||||
|
|
||||||
|
all_things = (things_with_files | things_with_links).distinct().order_by('name')
|
||||||
|
|
||||||
|
resources = []
|
||||||
|
for thing in all_things.prefetch_related('files', 'links'):
|
||||||
|
for file in thing.files.all():
|
||||||
|
resources.append({
|
||||||
|
'type': 'file',
|
||||||
|
'thing_name': thing.name,
|
||||||
|
'thing_id': thing.id,
|
||||||
|
'title': file.title,
|
||||||
|
'url': file.file.url,
|
||||||
|
})
|
||||||
|
for link in thing.links.all():
|
||||||
|
resources.append({
|
||||||
|
'type': 'link',
|
||||||
|
'thing_name': thing.name,
|
||||||
|
'thing_id': thing.id,
|
||||||
|
'title': link.title,
|
||||||
|
'url': link.url,
|
||||||
|
})
|
||||||
|
|
||||||
|
return render(request, 'boxes/resources_list.html', {
|
||||||
|
'resources': resources,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def fixme(request):
|
||||||
|
"""Page to find and fix things missing tags for specific facets."""
|
||||||
|
facets = Facet.objects.all().prefetch_related('tags')
|
||||||
|
|
||||||
|
selected_facet = None
|
||||||
|
missing_things = []
|
||||||
|
|
||||||
|
if request.method == 'GET' and 'facet_id' in request.GET:
|
||||||
|
try:
|
||||||
|
selected_facet = Facet.objects.get(pk=request.GET['facet_id'])
|
||||||
|
# Find things that don't have any tag from this facet
|
||||||
|
missing_things = Thing.objects.exclude(
|
||||||
|
tags__facet=selected_facet
|
||||||
|
).select_related('box', 'box__box_type').prefetch_related('tags')
|
||||||
|
except Facet.DoesNotExist:
|
||||||
|
selected_facet = None
|
||||||
|
|
||||||
|
elif request.method == 'POST':
|
||||||
|
facet_id = request.POST.get('facet_id')
|
||||||
|
tag_ids = request.POST.getlist('tag_ids')
|
||||||
|
thing_ids = request.POST.getlist('thing_ids')
|
||||||
|
|
||||||
|
if facet_id and tag_ids and thing_ids:
|
||||||
|
facet = get_object_or_404(Facet, pk=facet_id)
|
||||||
|
tags = Tag.objects.filter(id__in=tag_ids, facet=facet)
|
||||||
|
things = Thing.objects.filter(id__in=thing_ids)
|
||||||
|
|
||||||
|
for thing in things:
|
||||||
|
if facet.cardinality == Facet.Cardinality.SINGLE:
|
||||||
|
# Remove existing tags from this facet
|
||||||
|
thing.tags.remove(*thing.tags.filter(facet=facet))
|
||||||
|
# Add new tags
|
||||||
|
for tag in tags:
|
||||||
|
if tag.facet == facet:
|
||||||
|
thing.tags.add(tag)
|
||||||
|
|
||||||
|
return redirect('fixme')
|
||||||
|
|
||||||
|
return render(request, 'boxes/fixme.html', {
|
||||||
|
'facets': facets,
|
||||||
|
'selected_facet': selected_facet,
|
||||||
|
'missing_things': missing_things,
|
||||||
|
})
|
||||||
|
|||||||
@@ -50,13 +50,31 @@
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar-toggle {
|
||||||
|
display: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #555;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-toggle:hover {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.navbar-nav {
|
.navbar-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-nav a {
|
.navbar-nav a,
|
||||||
|
.navbar-nav form {
|
||||||
color: #555;
|
color: #555;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -81,6 +99,68 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar-nav button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #555;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.navbar {
|
||||||
|
padding: 15px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-toggle {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav a,
|
||||||
|
.navbar-nav form {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav a:first-child,
|
||||||
|
.navbar-nav form:first-child {
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav a:last-child,
|
||||||
|
.navbar-nav form:last-child {
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 20px auto;
|
margin: 20px auto;
|
||||||
@@ -216,6 +296,97 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-content {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
background: white;
|
||||||
|
min-width: 200px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 0;
|
||||||
|
z-index: 1000;
|
||||||
|
top: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-content a {
|
||||||
|
display: block;
|
||||||
|
padding: 12px 20px;
|
||||||
|
color: #555;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-content a:hover {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-content a:first-child {
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-content a:last-child {
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-content button:hover {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown:hover .dropdown-content,
|
||||||
|
.dropdown:focus-within .dropdown-content {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #667eea;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-btn:hover {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dropdown-content {
|
||||||
|
position: static;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-content a {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
</style>
|
</style>
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
@@ -226,18 +397,30 @@
|
|||||||
<i class="fas fa-flask"></i>
|
<i class="fas fa-flask"></i>
|
||||||
LabHelper
|
LabHelper
|
||||||
</a>
|
</a>
|
||||||
<div class="navbar-nav">
|
<button class="navbar-toggle" id="navbar-toggle">
|
||||||
|
<i class="fas fa-bars"></i>
|
||||||
|
</button>
|
||||||
|
<div class="navbar-nav" id="navbar-nav">
|
||||||
<a href="/"><i class="fas fa-home"></i> Home</a>
|
<a href="/"><i class="fas fa-home"></i> Home</a>
|
||||||
<a href="/box-management/"><i class="fas fa-boxes"></i> Box Management</a>
|
|
||||||
<a href="/search/"><i class="fas fa-search"></i> Search</a>
|
<a href="/search/"><i class="fas fa-search"></i> Search</a>
|
||||||
<a href="/admin/"><i class="fas fa-cog"></i> Admin</a>
|
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<form method="post" action="{% url 'logout' %}" style="display: inline;">
|
<div class="dropdown">
|
||||||
{% csrf_token %}
|
<button class="dropdown-btn">
|
||||||
<button type="submit" style="background: none; border: none; color: #555; font: inherit; cursor: pointer; padding: 8px 16px; border-radius: 8px; transition: all 0.3s ease; display: inline-flex; align-items: center; gap: 8px;">
|
<i class="fas fa-user"></i> {{ user.username }} <i class="fas fa-chevron-down"></i>
|
||||||
<i class="fas fa-sign-out-alt"></i> Logout ({{ user.username }})
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
<div class="dropdown-content">
|
||||||
|
<a href="/box-management/"><i class="fas fa-boxes"></i> Box Management</a>
|
||||||
|
<a href="/resources/"><i class="fas fa-folder-open"></i> Resources</a>
|
||||||
|
<a href="/fixme/"><i class="fas fa-exclamation-triangle"></i> Fixme</a>
|
||||||
|
<a href="/admin/"><i class="fas fa-cog"></i> Admin</a>
|
||||||
|
<form method="post" action="{% url 'logout' %}" style="padding: 0; margin: 0; background: none; border: none;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" style="width: 100%; text-align: left; padding: 12px 20px; background: none; border: none; color: #555; font: inherit; cursor: pointer; display: block; transition: all 0.2s ease;">
|
||||||
|
<i class="fas fa-sign-out-alt"></i> Logout
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{% url 'login' %}"><i class="fas fa-sign-in-alt"></i> Login</a>
|
<a href="{% url 'login' %}"><i class="fas fa-sign-in-alt"></i> Login</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -255,5 +438,18 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('#navbar-toggle').on('click', function() {
|
||||||
|
$('#navbar-nav').toggleClass('active');
|
||||||
|
const icon = $(this).find('i');
|
||||||
|
if ($('#navbar-nav').hasClass('active')) {
|
||||||
|
icon.removeClass('fa-bars').addClass('fa-times');
|
||||||
|
} else {
|
||||||
|
icon.removeClass('fa-times').addClass('fa-bars');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -26,12 +26,15 @@ from boxes.views import (
|
|||||||
add_things,
|
add_things,
|
||||||
box_detail,
|
box_detail,
|
||||||
box_management,
|
box_management,
|
||||||
|
boxes_list,
|
||||||
delete_box,
|
delete_box,
|
||||||
delete_box_type,
|
delete_box_type,
|
||||||
edit_box,
|
edit_box,
|
||||||
edit_box_type,
|
edit_box_type,
|
||||||
|
edit_thing,
|
||||||
|
fixme,
|
||||||
index,
|
index,
|
||||||
search,
|
resources_list,
|
||||||
search_api,
|
search_api,
|
||||||
thing_detail,
|
thing_detail,
|
||||||
)
|
)
|
||||||
@@ -49,9 +52,13 @@ urlpatterns = [
|
|||||||
path('box/<str:box_id>/delete/', delete_box, name='delete_box'),
|
path('box/<str:box_id>/delete/', delete_box, name='delete_box'),
|
||||||
path('box/<str:box_id>/', box_detail, name='box_detail'),
|
path('box/<str:box_id>/', box_detail, name='box_detail'),
|
||||||
path('thing/<int:thing_id>/', thing_detail, name='thing_detail'),
|
path('thing/<int:thing_id>/', thing_detail, name='thing_detail'),
|
||||||
|
path('thing/<int:thing_id>/edit/', edit_thing, name='edit_thing'),
|
||||||
path('box/<str:box_id>/add/', add_things, name='add_things'),
|
path('box/<str:box_id>/add/', add_things, name='add_things'),
|
||||||
path('search/', search, name='search'),
|
path('boxes/', boxes_list, name='boxes_list'),
|
||||||
|
path('search/', boxes_list, name='search'),
|
||||||
path('search/api/', search_api, name='search_api'),
|
path('search/api/', search_api, name='search_api'),
|
||||||
|
path('resources/', resources_list, name='resources_list'),
|
||||||
|
path('fixme/', fixme, name='fixme'),
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user