Compare commits
24 Commits
improvemen
...
7410f8c607
| Author | SHA1 | Date | |
|---|---|---|---|
| 7410f8c607 | |||
| b465e7365f | |||
| a4f9274da4 | |||
| acde0cb2f8 | |||
| 10cc24ff03 | |||
| c566e31ab5 | |||
| bd36132946 | |||
| 20e5e0b0c1 | |||
| 0f5011d8f7 | |||
| 88a5c12bbc | |||
| 17e713964c | |||
| e172e2f9dc | |||
| eb8284fdd2 | |||
| 1d1c80a267 | |||
| d28c13d339 | |||
| 0eeedaff97 | |||
| b0b44eeed4 | |||
| 39762037fe | |||
| 150fd1c59d | |||
| 8d23713526 | |||
| 4d3ace5395 | |||
| 2a2d3ead0b | |||
| fe39d2c067 | |||
| 9db47a0ab7 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,7 +12,5 @@ keys/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
package.json
|
||||
|
||||
# Diagram cache directory
|
||||
/data
|
||||
.env
|
||||
data/db.sqlite3
|
||||
|
||||
136
AGENTS.md
136
AGENTS.md
@@ -205,6 +205,136 @@ The project includes these pre-installed packages:
|
||||
- **django-nested-admin**: Nested inline forms in admin
|
||||
- **django-nested-inline**: Additional nested inline support
|
||||
- **django-revproxy**: Reverse proxy functionality
|
||||
- **sorl-thumbnail**: Image thumbnailing
|
||||
- **Font Awesome**: Icon library (loaded via CDN)
|
||||
- **jQuery**: JavaScript library (loaded via CDN)
|
||||
|
||||
## Frontend/CSS Guidelines
|
||||
|
||||
### Base Template
|
||||
|
||||
The project uses a base template system at `labhelper/templates/base.html`. All page templates should extend this base:
|
||||
|
||||
```django
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Page Title - LabHelper{% endblock %}
|
||||
|
||||
{% block page_header %}
|
||||
<!-- Optional page header with breadcrumbs -->
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Main page content -->
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
{% block extra_js %}{% endblock %}
|
||||
```
|
||||
|
||||
### Design System
|
||||
|
||||
**Color Scheme:**
|
||||
- Primary gradient: `#667eea` (purple) to `#764ba2` (purple-blue)
|
||||
- Success: Green gradient
|
||||
- Error: Red gradient
|
||||
- Background: Light gray `#f5f5f5` with gradient overlays
|
||||
- Cards: White with subtle shadows
|
||||
|
||||
**Components:**
|
||||
- **Navigation**: Glassmorphism effect with blur backdrop
|
||||
- **Buttons**: Gradient backgrounds with hover lift effect
|
||||
- **Cards**: White with rounded corners and box shadows
|
||||
- **Tables**: Gradient headers with hover row effects
|
||||
- **Alerts**: Gradient backgrounds with icons
|
||||
- **Form Inputs**: Focused states with color transitions
|
||||
|
||||
**Typography:**
|
||||
- System fonts: `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif`
|
||||
- Headings: Bold, colored
|
||||
- Body: Regular, dark gray
|
||||
|
||||
**Icons:**
|
||||
- Font Awesome 6.5.1 (CDN)
|
||||
- Use semantic icons for actions
|
||||
- Color: Match context or inherit from parent
|
||||
|
||||
**Responsive Design:**
|
||||
- Mobile-first approach
|
||||
- Grid layouts with `repeat(auto-fill, minmax(250px, 1fr))`
|
||||
- Flexbox for component layouts
|
||||
- Breakpoints handled by grid and flex-wrap
|
||||
|
||||
### CSS Guidelines
|
||||
|
||||
**Naming:**
|
||||
- Use descriptive class names
|
||||
- BEM pattern encouraged for complex components
|
||||
- Inline styles allowed for template-specific styling
|
||||
|
||||
**Styles:**
|
||||
- Use base template styles when possible
|
||||
- Template-specific styles in `{% block extra_css %}`
|
||||
- JavaScript in `{% block extra_js %}`
|
||||
- Smooth transitions (0.2s - 0.3s)
|
||||
- Hover effects with transform and box-shadow
|
||||
|
||||
**jQuery Usage:**
|
||||
- Loaded in base template
|
||||
- Use for interactive elements (toggles, hovers)
|
||||
- Event delegation for dynamically added elements
|
||||
- Focus/blur events for form inputs
|
||||
|
||||
### Available Pages/Views
|
||||
|
||||
| View Name | URL Pattern | Description |
|
||||
|-----------|--------------|-------------|
|
||||
| `index` | `/` | Home page with boxes grid and thing types tree |
|
||||
| `box_detail` | `/box/<str:box_id>/` | List items in a specific box |
|
||||
| `thing_detail` | `/thing/<int:thing_id>/` | Thing details with move-to-box form |
|
||||
| `thing_type_detail` | `/thing-type/<int:type_id>/` | Thing type hierarchy and items |
|
||||
| `add_things` | `/box/<str:box_id>/add/` | Form to add/edit items in a box |
|
||||
| `search` | `/search/` | Search page with AJAX autocomplete |
|
||||
| `search_api` | `/search/api/` | AJAX endpoint for search results |
|
||||
|
||||
### Template Best Practices
|
||||
|
||||
1. **Always extend base template**
|
||||
```django
|
||||
{% extends "base.html" %}
|
||||
```
|
||||
|
||||
2. **Use block system for content injection**
|
||||
- `title`: Page title tag
|
||||
- `page_header`: Page header with breadcrumbs
|
||||
- `content`: Main page content
|
||||
- `extra_css`: Additional styles
|
||||
- `extra_js`: Additional JavaScript
|
||||
|
||||
3. **Load required template tags**
|
||||
```django
|
||||
{% load static %}
|
||||
{% load mptt_tags %}
|
||||
{% load thumbnail %}
|
||||
```
|
||||
|
||||
4. **Use URL names for links**
|
||||
```django
|
||||
<a href="{% url 'box_detail' box.id %}">
|
||||
```
|
||||
|
||||
5. **Use icons with Font Awesome**
|
||||
```django
|
||||
<i class="fas fa-box"></i>
|
||||
```
|
||||
|
||||
6. **Add breadcrumbs for navigation**
|
||||
```django
|
||||
<p class="breadcrumb">
|
||||
<a href="/"><i class="fas fa-home"></i> Home</a> /
|
||||
<a href="/box/{{ box.id }}/"><i class="fas fa-box"></i> Box {{ box.id }}</a>
|
||||
</p>
|
||||
```
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
@@ -241,9 +371,11 @@ Per `.gitignore`:
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Always activate venv**: `source .venv/bin/activate`
|
||||
2. **Run migrations after model changes**: `makemigrations` then `migrate`
|
||||
1. **NEVER commit or push without explicit permission**: Always ask the user before running `git commit` or `git push`. The user will explicitly say "commit and push" when they want you to do this. Do NOT automatically commit/push after making changes unless instructed to do so.
|
||||
2. **Always activate venv**: `source .venv/bin/activate`
|
||||
3. **Run migrations after model changes**: `makemigrations` then `migrate`
|
||||
3. **Add new apps to INSTALLED_APPS** in `settings.py`
|
||||
4. **Templates in labhelper/templates/**: The base template and shared templates are in `labhelper/templates/`. App-specific templates remain in `app_name/templates/`.
|
||||
4. **Use get_object_or_404** instead of bare `.get()` calls
|
||||
5. **Never commit SECRET_KEY** - use environment variables in production
|
||||
|
||||
|
||||
272
AGENTS.md.backup
Normal file
272
AGENTS.md.backup
Normal file
@@ -0,0 +1,272 @@
|
||||
# AGENTS.md - AI Coding Agent Guidelines
|
||||
|
||||
This document provides guidelines for AI coding agents working in the labhelper repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
- **Type**: Django web application
|
||||
- **Python**: 3.13.7
|
||||
- **Django**: 5.2.9
|
||||
- **Database**: SQLite (development)
|
||||
- **Virtual Environment**: `.venv/`
|
||||
|
||||
## Build/Run Commands
|
||||
|
||||
### Development Server
|
||||
|
||||
```bash
|
||||
python manage.py runserver # Start dev server on port 8000
|
||||
python manage.py runserver 0.0.0.0:8000 # Bind to all interfaces
|
||||
```
|
||||
|
||||
### Database Operations
|
||||
|
||||
```bash
|
||||
python manage.py makemigrations # Create migration files
|
||||
python manage.py makemigrations boxes # Create migrations for specific app
|
||||
python manage.py migrate # Apply all migrations
|
||||
python manage.py showmigrations # List migration status
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
python manage.py test
|
||||
|
||||
# Run tests for a specific app
|
||||
python manage.py test boxes
|
||||
|
||||
# Run a specific test class
|
||||
python manage.py test boxes.tests.TestClassName
|
||||
|
||||
# Run a single test method
|
||||
python manage.py test boxes.tests.TestClassName.test_method_name
|
||||
|
||||
# Run tests with verbosity
|
||||
python manage.py test -v 2
|
||||
|
||||
# Run tests with coverage
|
||||
coverage run manage.py test
|
||||
coverage report
|
||||
coverage html # Generate HTML report
|
||||
```
|
||||
|
||||
### Django Shell
|
||||
|
||||
```bash
|
||||
python manage.py shell # Interactive Django shell
|
||||
python manage.py createsuperuser # Create admin user
|
||||
python manage.py collectstatic # Collect static files
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
gunicorn labhelper.wsgi:application # Run with Gunicorn
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Python Style
|
||||
|
||||
- Follow PEP 8 conventions
|
||||
- Use 4-space indentation (no tabs)
|
||||
- Maximum line length: 79 characters (PEP 8 standard)
|
||||
- Use single quotes for strings: `'string'`
|
||||
- Use double quotes for docstrings: `"""Docstring."""`
|
||||
|
||||
### Import Order
|
||||
|
||||
Organize imports in this order, with blank lines between groups:
|
||||
|
||||
```python
|
||||
# 1. Standard library imports
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 2. Django imports
|
||||
from django.db import models
|
||||
from django.contrib import admin
|
||||
from django.shortcuts import render, redirect
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
|
||||
# 3. Third-party imports
|
||||
import requests
|
||||
from markdown import markdown
|
||||
|
||||
# 4. Local application imports
|
||||
from .models import MyModel
|
||||
from .forms import MyForm
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
| Type | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| Modules | lowercase_with_underscores | `user_profile.py` |
|
||||
| Classes | PascalCase | `UserProfile` |
|
||||
| Functions | lowercase_with_underscores | `get_user_data()` |
|
||||
| Constants | UPPERCASE_WITH_UNDERSCORES | `MAX_CONNECTIONS` |
|
||||
| Variables | lowercase_with_underscores | `user_count` |
|
||||
| Django Models | PascalCase (singular) | `Box`, `UserProfile` |
|
||||
| Django Apps | lowercase (short) | `boxes`, `users` |
|
||||
|
||||
### Django-Specific Conventions
|
||||
|
||||
**Models:**
|
||||
```python
|
||||
from django.db import models
|
||||
|
||||
class Box(models.Model):
|
||||
"""A storage box in the lab."""
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = 'boxes'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
```
|
||||
|
||||
**Views:**
|
||||
```python
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.http import Http404
|
||||
|
||||
def box_detail(request, box_id):
|
||||
"""Display details for a specific box."""
|
||||
box = get_object_or_404(Box, pk=box_id)
|
||||
return render(request, 'boxes/detail.html', {'box': box})
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```python
|
||||
# Use specific exceptions
|
||||
try:
|
||||
result = some_operation()
|
||||
except SpecificError as exc:
|
||||
raise CustomError('Descriptive message') from exc
|
||||
|
||||
# Django: Use get_object_or_404 for model lookups
|
||||
box = get_object_or_404(Box, pk=box_id)
|
||||
|
||||
# Log errors appropriately
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error('Error message: %s', error_detail)
|
||||
```
|
||||
|
||||
### Type Hints (Recommended)
|
||||
|
||||
```python
|
||||
from typing import Optional
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
|
||||
def get_box(request: HttpRequest, box_id: int) -> HttpResponse:
|
||||
"""Retrieve a box by ID."""
|
||||
...
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
labhelper/
|
||||
├── manage.py # Django CLI entry point
|
||||
├── requirements.txt # Python dependencies
|
||||
├── labhelper/ # Project configuration
|
||||
│ ├── settings.py # Django settings
|
||||
│ ├── urls.py # Root URL routing
|
||||
│ ├── wsgi.py # WSGI application
|
||||
│ └── asgi.py # ASGI application
|
||||
└── boxes/ # Django app
|
||||
├── admin.py # Admin configuration
|
||||
├── apps.py # App configuration
|
||||
├── models.py # Data models
|
||||
├── views.py # View functions
|
||||
├── tests.py # Test cases
|
||||
├── migrations/ # Database migrations
|
||||
└── templates/ # HTML templates
|
||||
```
|
||||
|
||||
## Available Django Extensions
|
||||
|
||||
The project includes these pre-installed packages:
|
||||
|
||||
- **django-mptt**: Tree structures (categories, hierarchies)
|
||||
- **django-mptt-admin**: Admin interface for MPTT models
|
||||
- **django-admin-sortable2**: Drag-and-drop ordering in admin
|
||||
- **django-nested-admin**: Nested inline forms in admin
|
||||
- **django-nested-inline**: Additional nested inline support
|
||||
- **django-revproxy**: Reverse proxy functionality
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Use `django.test.TestCase` for database tests
|
||||
- Use `django.test.SimpleTestCase` for tests without database
|
||||
- Name test files `test_*.py` or `*_tests.py`
|
||||
- Name test methods `test_*`
|
||||
- Use descriptive test method names
|
||||
|
||||
```python
|
||||
from django.test import TestCase
|
||||
from .models import Box
|
||||
|
||||
class BoxModelTests(TestCase):
|
||||
"""Tests for the Box model."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.box = Box.objects.create(name='Test Box')
|
||||
|
||||
def test_box_str_returns_name(self):
|
||||
"""Box __str__ should return the box name."""
|
||||
self.assertEqual(str(self.box), 'Test Box')
|
||||
```
|
||||
|
||||
## Files to Never Commit
|
||||
|
||||
Per `.gitignore`:
|
||||
- `__pycache__/`, `*.pyc` - Python bytecode
|
||||
- `.venv/` - Virtual environment
|
||||
- `.env` - Environment variables
|
||||
- `data/db.sqlite3` - Database file
|
||||
- `keys/` - Secret keys
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Always activate venv**: `source .venv/bin/activate`
|
||||
2. **Run migrations after model changes**: `makemigrations` then `migrate`
|
||||
3. **Add new apps to INSTALLED_APPS** in `settings.py`
|
||||
4. **Use get_object_or_404** instead of bare `.get()` calls
|
||||
5. **Never commit SECRET_KEY** - use environment variables in production
|
||||
|
||||
## Deployment Commands
|
||||
|
||||
### Prepare a Full Deployment
|
||||
|
||||
When instructed to "Prepare a full deployment", perform the following steps:
|
||||
|
||||
1. **Bump container versions**: In `argocd/deployment.yaml`, increment the version numbers by 0.001 for both containers:
|
||||
- `labhelper-data-loader` (initContainer)
|
||||
- `labhelper` (main container)
|
||||
|
||||
2. **Copy database**: Copy the current development database to the data-loader preload location:
|
||||
```bash
|
||||
cp data/db.sqlite3 data-loader/preload.sqlite3
|
||||
```
|
||||
|
||||
### Prepare a Partial Deployment
|
||||
|
||||
When instructed to "Prepare a partial deployment", perform the following step:
|
||||
|
||||
1. **Bump main container version only**: In `argocd/deployment.yaml`, increment the version number by 0.001 for the main container only:
|
||||
- `labhelper` (main container)
|
||||
|
||||
Do NOT bump the data-loader version or copy the database.
|
||||
@@ -18,19 +18,25 @@ spec:
|
||||
fsGroupChangePolicy: "OnRootMismatch"
|
||||
initContainers:
|
||||
- name: loader
|
||||
image: git.baumann.gr/adebaumann/labhelper-data-loader:0.007
|
||||
image: git.baumann.gr/adebaumann/labhelper-data-loader:0.012
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
command: [ "sh","-c","cp -n preload/preload.sqlite3 /data/db.sqlite3; mkdir -p /data/media/cache /data/media/things; chmod -R 775 /data/media; exit 0" ]
|
||||
command: [ "sh","-c","if [ ! -f /data/db.sqlite3 ] || [ ! -s /data/db.sqlite3 ]; then cp preload/preload.sqlite3 /data/db.sqlite3 && echo 'Database copied from preload'; else echo 'Existing database preserved'; fi" ]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
containers:
|
||||
- name: web
|
||||
image: git.baumann.gr/adebaumann/labhelper:0.028
|
||||
image: git.baumann.gr/adebaumann/labhelper:0.045
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
env:
|
||||
- name: DJANGO_SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: django-secret
|
||||
key: secret-key
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
|
||||
8
argocd/secret.yaml
Normal file
8
argocd/secret.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: django-secret
|
||||
namespace: labhelper
|
||||
type: Opaque
|
||||
stringData:
|
||||
secret-key: "CHANGE_ME_TO_RANDOM_STRING"
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from django_mptt_admin.admin import DjangoMpttAdmin
|
||||
|
||||
from .models import Box, BoxType, Thing, ThingType
|
||||
from .models import Box, BoxType, Thing, ThingFile, ThingLink, ThingType
|
||||
|
||||
|
||||
@admin.register(BoxType)
|
||||
@@ -35,3 +35,32 @@ class ThingAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'thing_type', 'box')
|
||||
list_filter = ('thing_type', 'box')
|
||||
search_fields = ('name', 'description')
|
||||
|
||||
|
||||
class ThingFileInline(admin.TabularInline):
|
||||
"""Inline admin for Thing files."""
|
||||
|
||||
model = ThingFile
|
||||
extra = 1
|
||||
fields = ('title', 'file')
|
||||
|
||||
|
||||
class ThingLinkInline(admin.TabularInline):
|
||||
"""Inline admin for Thing links."""
|
||||
|
||||
model = ThingLink
|
||||
extra = 1
|
||||
fields = ('title', 'url')
|
||||
|
||||
|
||||
class ThingAdminWithFiles(admin.ModelAdmin):
|
||||
"""Admin configuration for Thing model with files and links."""
|
||||
|
||||
list_display = ('name', 'thing_type', 'box')
|
||||
list_filter = ('thing_type', 'box')
|
||||
search_fields = ('name', 'description')
|
||||
inlines = [ThingFileInline, ThingLinkInline]
|
||||
|
||||
|
||||
admin.site.unregister(Thing)
|
||||
admin.register(Thing, ThingAdminWithFiles)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django import forms
|
||||
|
||||
from .models import Thing
|
||||
from .models import Box, BoxType, Thing, ThingFile, ThingLink
|
||||
|
||||
|
||||
class ThingForm(forms.ModelForm):
|
||||
@@ -16,6 +16,64 @@ class ThingForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class ThingPictureForm(forms.ModelForm):
|
||||
"""Form for uploading/changing a Thing picture."""
|
||||
|
||||
class Meta:
|
||||
model = Thing
|
||||
fields = ('picture',)
|
||||
|
||||
|
||||
class ThingFileForm(forms.ModelForm):
|
||||
"""Form for adding a file to a Thing."""
|
||||
|
||||
class Meta:
|
||||
model = ThingFile
|
||||
fields = ('title', 'file')
|
||||
widgets = {
|
||||
'title': forms.TextInput(attrs={'style': 'width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}),
|
||||
'file': forms.FileInput(attrs={'style': 'width: 100%;'}),
|
||||
}
|
||||
|
||||
|
||||
class ThingLinkForm(forms.ModelForm):
|
||||
"""Form for adding a link to a Thing."""
|
||||
|
||||
class Meta:
|
||||
model = ThingLink
|
||||
fields = ('title', 'url')
|
||||
widgets = {
|
||||
'title': forms.TextInput(attrs={'style': 'width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}),
|
||||
'url': forms.URLInput(attrs={'style': 'width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}),
|
||||
}
|
||||
|
||||
|
||||
class BoxTypeForm(forms.ModelForm):
|
||||
"""Form for adding/editing a BoxType."""
|
||||
|
||||
class Meta:
|
||||
model = BoxType
|
||||
fields = ('name', 'width', 'height', 'length')
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'style': 'width: 100%; max-width: 300px; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}),
|
||||
'width': forms.NumberInput(attrs={'style': 'width: 100%; max-width: 150px; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}),
|
||||
'height': forms.NumberInput(attrs={'style': 'width: 100%; max-width: 150px; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}),
|
||||
'length': forms.NumberInput(attrs={'style': 'width: 100%; max-width: 150px; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}),
|
||||
}
|
||||
|
||||
|
||||
class BoxForm(forms.ModelForm):
|
||||
"""Form for adding/editing a Box."""
|
||||
|
||||
class Meta:
|
||||
model = Box
|
||||
fields = ('id', 'box_type')
|
||||
widgets = {
|
||||
'id': forms.TextInput(attrs={'style': 'width: 100%; max-width: 200px; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px; text-transform: uppercase;'}),
|
||||
'box_type': forms.Select(attrs={'style': 'width: 100%; max-width: 300px; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}),
|
||||
}
|
||||
|
||||
|
||||
ThingFormSet = forms.modelformset_factory(
|
||||
Thing,
|
||||
form=ThingForm,
|
||||
|
||||
0
boxes/management/__init__.py
Normal file
0
boxes/management/__init__.py
Normal file
0
boxes/management/commands/__init__.py
Normal file
0
boxes/management/commands/__init__.py
Normal file
79
boxes/management/commands/clean_orphaned_files.py
Normal file
79
boxes/management/commands/clean_orphaned_files.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import os
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from boxes.models import ThingFile
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Clean up orphaned files from deleted things'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
dest='dry_run',
|
||||
help='Show what would be deleted without actually deleting',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options.get('dry_run', False)
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING('DRY RUN - No files will be deleted'))
|
||||
|
||||
self.stdout.write('Finding orphaned files...')
|
||||
|
||||
media_root = settings.MEDIA_ROOT
|
||||
things_files_root = os.path.join(media_root, 'things', 'files')
|
||||
|
||||
if not os.path.exists(things_files_root):
|
||||
self.stdout.write(self.style.WARNING('No things/files directory found'))
|
||||
return
|
||||
|
||||
valid_paths = set()
|
||||
for thing_file in ThingFile.objects.all():
|
||||
if thing_file.file:
|
||||
file_path = thing_file.file.path
|
||||
if os.path.exists(file_path):
|
||||
valid_paths.add(os.path.relpath(file_path, things_files_root))
|
||||
|
||||
self.stdout.write(f'Found {len(valid_paths)} valid files in database')
|
||||
|
||||
deleted_count = 0
|
||||
empty_dirs_removed = 0
|
||||
|
||||
for root, dirs, files in os.walk(things_files_root, topdown=False):
|
||||
for filename in files:
|
||||
file_path = os.path.join(root, filename)
|
||||
relative_path = os.path.relpath(file_path, things_files_root)
|
||||
|
||||
if relative_path not in valid_paths:
|
||||
deleted_count += 1
|
||||
if dry_run:
|
||||
self.stdout.write(f'Would delete: {file_path}')
|
||||
else:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
self.stdout.write(f'Deleted: {file_path}')
|
||||
except OSError as e:
|
||||
self.stdout.write(self.style.ERROR(f'Failed to delete {file_path}: {e}'))
|
||||
|
||||
for dirname in dirs:
|
||||
dir_path = os.path.join(root, dirname)
|
||||
if not os.listdir(dir_path):
|
||||
if dry_run:
|
||||
self.stdout.write(f'Would remove empty directory: {dir_path}')
|
||||
else:
|
||||
try:
|
||||
os.rmdir(dir_path)
|
||||
self.stdout.write(f'Removed empty directory: {dir_path}')
|
||||
empty_dirs_removed += 1
|
||||
except OSError as e:
|
||||
self.stdout.write(self.style.ERROR(f'Failed to remove {dir_path}: {e}'))
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING(f'\nDry run complete. Would delete {deleted_count} files'))
|
||||
self.stdout.write(f'Would remove {empty_dirs_removed} empty directories')
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(f'\nCleanup complete! Deleted {deleted_count} orphaned files'))
|
||||
self.stdout.write(f'Removed {empty_dirs_removed} empty directories')
|
||||
147
boxes/management/commands/clean_orphaned_images.py
Normal file
147
boxes/management/commands/clean_orphaned_images.py
Normal file
@@ -0,0 +1,147 @@
|
||||
import json
|
||||
import os
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import F
|
||||
from sorl.thumbnail.models import KVStore
|
||||
from boxes.models import Thing
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Clean up orphaned images and thumbnails from deleted things'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
dest='dry_run',
|
||||
help='Show what would be deleted without actually deleting',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options.get('dry_run', False)
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING('DRY RUN - No files will be deleted'))
|
||||
|
||||
self.stdout.write('Finding orphaned images and thumbnails...')
|
||||
|
||||
media_root = settings.MEDIA_ROOT
|
||||
cache_root = os.path.join(media_root, 'cache')
|
||||
things_root = os.path.join(media_root, 'things')
|
||||
|
||||
if not os.path.exists(things_root):
|
||||
self.stdout.write(self.style.WARNING('No things directory found'))
|
||||
return
|
||||
|
||||
valid_paths = set()
|
||||
for thing in Thing.objects.exclude(picture__exact='').exclude(picture__isnull=True):
|
||||
if thing.picture:
|
||||
valid_paths.add(os.path.basename(thing.picture.name))
|
||||
|
||||
self.stdout.write(f'Found {len(valid_paths)} valid images in database')
|
||||
|
||||
orphaned_thumbnail_paths = set()
|
||||
db_cache_paths = set()
|
||||
|
||||
for kvstore in KVStore.objects.filter(key__startswith='sorl-thumbnail||image||'):
|
||||
try:
|
||||
data = json.loads(kvstore.value)
|
||||
name = data.get('name', '')
|
||||
if name.startswith('things/'):
|
||||
filename = os.path.basename(name)
|
||||
if filename not in valid_paths:
|
||||
image_hash = kvstore.key.split('||')[-1]
|
||||
thumbnail_kvstore = KVStore.objects.filter(key=f'sorl-thumbnail||thumbnails||{image_hash}').first()
|
||||
if thumbnail_kvstore:
|
||||
thumbnail_list = json.loads(thumbnail_kvstore.value)
|
||||
for thumbnail_hash in thumbnail_list:
|
||||
thumbnail_image_kvstore = KVStore.objects.filter(key=f'sorl-thumbnail||image||{thumbnail_hash}').first()
|
||||
if thumbnail_image_kvstore:
|
||||
thumbnail_data = json.loads(thumbnail_image_kvstore.value)
|
||||
thumbnail_path = thumbnail_data.get('name', '')
|
||||
if thumbnail_path.startswith('cache/'):
|
||||
orphaned_thumbnail_paths.add(thumbnail_path)
|
||||
elif name.startswith('cache/'):
|
||||
db_cache_paths.add(name)
|
||||
except (json.JSONDecodeError, KeyError, AttributeError):
|
||||
pass
|
||||
|
||||
deleted_count = 0
|
||||
thumbnail_deleted_count = 0
|
||||
empty_dirs_removed = 0
|
||||
|
||||
for root, dirs, files in os.walk(things_root, topdown=False):
|
||||
for filename in files:
|
||||
file_path = os.path.join(root, filename)
|
||||
relative_path = os.path.relpath(file_path, things_root)
|
||||
|
||||
if relative_path not in valid_paths:
|
||||
deleted_count += 1
|
||||
if dry_run:
|
||||
self.stdout.write(f'Would delete: {file_path}')
|
||||
else:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
self.stdout.write(f'Deleted: {file_path}')
|
||||
except OSError as e:
|
||||
self.stdout.write(self.style.ERROR(f'Failed to delete {file_path}: {e}'))
|
||||
|
||||
for dirname in dirs:
|
||||
dir_path = os.path.join(root, dirname)
|
||||
if not os.listdir(dir_path):
|
||||
if dry_run:
|
||||
self.stdout.write(f'Would remove empty directory: {dir_path}')
|
||||
else:
|
||||
try:
|
||||
os.rmdir(dir_path)
|
||||
self.stdout.write(f'Removed empty directory: {dir_path}')
|
||||
empty_dirs_removed += 1
|
||||
except OSError as e:
|
||||
self.stdout.write(self.style.ERROR(f'Failed to remove {dir_path}: {e}'))
|
||||
|
||||
if os.path.exists(cache_root):
|
||||
for root, dirs, files in os.walk(cache_root, topdown=False):
|
||||
for filename in files:
|
||||
file_path = os.path.join(root, filename)
|
||||
relative_path = os.path.relpath(file_path, media_root)
|
||||
|
||||
if relative_path in orphaned_thumbnail_paths:
|
||||
thumbnail_deleted_count += 1
|
||||
if dry_run:
|
||||
self.stdout.write(f'Would delete thumbnail (orphaned image): {file_path}')
|
||||
else:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
self.stdout.write(f'Deleted thumbnail (orphaned image): {file_path}')
|
||||
except OSError as e:
|
||||
self.stdout.write(self.style.ERROR(f'Failed to delete {file_path}: {e}'))
|
||||
elif relative_path not in db_cache_paths:
|
||||
thumbnail_deleted_count += 1
|
||||
if dry_run:
|
||||
self.stdout.write(f'Would delete thumbnail (no db entry): {file_path}')
|
||||
else:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
self.stdout.write(f'Deleted thumbnail (no db entry): {file_path}')
|
||||
except OSError as e:
|
||||
self.stdout.write(self.style.ERROR(f'Failed to delete {file_path}: {e}'))
|
||||
|
||||
for dirname in dirs:
|
||||
dir_path = os.path.join(root, dirname)
|
||||
if not os.listdir(dir_path):
|
||||
if dry_run:
|
||||
self.stdout.write(f'Would remove empty cache directory: {dir_path}')
|
||||
else:
|
||||
try:
|
||||
os.rmdir(dir_path)
|
||||
empty_dirs_removed += 1
|
||||
except OSError as e:
|
||||
self.stdout.write(self.style.ERROR(f'Failed to remove {dir_path}: {e}'))
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING(f'\nDry run complete. Would delete {deleted_count} images and {thumbnail_deleted_count} thumbnails'))
|
||||
self.stdout.write(f'Would remove {empty_dirs_removed} empty directories')
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(f'\nCleanup complete! Deleted {deleted_count} images and {thumbnail_deleted_count} thumbnails'))
|
||||
self.stdout.write(f'Removed {empty_dirs_removed} empty directories')
|
||||
19
boxes/migrations/0004_alter_thing_picture.py
Normal file
19
boxes/migrations/0004_alter_thing_picture.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-29 18:26
|
||||
|
||||
import boxes.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('boxes', '0003_convert_thingtype_to_mptt'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='thing',
|
||||
name='picture',
|
||||
field=models.ImageField(blank=True, upload_to=boxes.models.thing_picture_upload_path),
|
||||
),
|
||||
]
|
||||
41
boxes/migrations/0005_thingfile_thinglink.py
Normal file
41
boxes/migrations/0005_thingfile_thinglink.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-01 13:15
|
||||
|
||||
import boxes.models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('boxes', '0004_alter_thing_picture'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ThingFile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file', models.FileField(upload_to=boxes.models.thing_file_upload_path)),
|
||||
('title', models.CharField(help_text='Descriptive name for the file', max_length=255)),
|
||||
('uploaded_at', models.DateTimeField(auto_now_add=True)),
|
||||
('thing', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='boxes.thing')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-uploaded_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ThingLink',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('url', models.URLField(max_length=2048)),
|
||||
('title', models.CharField(help_text='Descriptive title for the link', max_length=255)),
|
||||
('uploaded_at', models.DateTimeField(auto_now_add=True)),
|
||||
('thing', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='links', to='boxes.thing')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-uploaded_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,7 +1,19 @@
|
||||
import os
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
|
||||
|
||||
def thing_picture_upload_path(instance, filename):
|
||||
"""Generate a custom path for thing pictures in format: <id>-<name>.<extension>"""
|
||||
extension = os.path.splitext(filename)[1]
|
||||
safe_name = slugify(instance.name)
|
||||
if instance.pk:
|
||||
return f'things/{instance.pk}-{safe_name}{extension}'
|
||||
else:
|
||||
return f'things/temp-{safe_name}{extension}'
|
||||
|
||||
|
||||
class BoxType(models.Model):
|
||||
"""A type of storage box with specific dimensions."""
|
||||
|
||||
@@ -72,10 +84,76 @@ class Thing(models.Model):
|
||||
related_name='things'
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
picture = models.ImageField(upload_to='things/', blank=True)
|
||||
picture = models.ImageField(upload_to=thing_picture_upload_path, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save to rename picture file after instance gets a pk."""
|
||||
if self.picture and not self.pk:
|
||||
picture = self.picture
|
||||
super().save(*args, **kwargs)
|
||||
new_path = thing_picture_upload_path(self, picture.name)
|
||||
if picture.name != new_path:
|
||||
try:
|
||||
old_path = self.picture.path
|
||||
if os.path.exists(old_path):
|
||||
new_full_path = os.path.join(os.path.dirname(old_path), os.path.basename(new_path))
|
||||
os.rename(old_path, new_full_path)
|
||||
self.picture.name = new_path
|
||||
super().save(update_fields=['picture'])
|
||||
except (AttributeError, FileNotFoundError):
|
||||
pass
|
||||
else:
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
def thing_file_upload_path(instance, filename):
|
||||
"""Generate a custom path for thing files in format: things/files/<thing_id>/<filename>"""
|
||||
return f'things/files/{instance.thing.id}/{filename}'
|
||||
|
||||
|
||||
class ThingFile(models.Model):
|
||||
"""A file attachment for a Thing."""
|
||||
|
||||
thing = models.ForeignKey(
|
||||
Thing,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='files'
|
||||
)
|
||||
file = models.FileField(upload_to=thing_file_upload_path)
|
||||
title = models.CharField(max_length=255, help_text='Descriptive name for the file')
|
||||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-uploaded_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.thing.name} - {self.title}'
|
||||
|
||||
def filename(self):
|
||||
"""Return the original filename."""
|
||||
return os.path.basename(self.file.name)
|
||||
|
||||
|
||||
class ThingLink(models.Model):
|
||||
"""A hyperlink for a Thing."""
|
||||
|
||||
thing = models.ForeignKey(
|
||||
Thing,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='links'
|
||||
)
|
||||
url = models.URLField(max_length=2048)
|
||||
title = models.CharField(max_length=255, help_text='Descriptive title for the link')
|
||||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-uploaded_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.thing.name} - {self.title}'
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% if formset.total_form_count %}
|
||||
<form method="post" style="overflow-x: auto;">
|
||||
<form method="post" enctype="multipart/form-data" style="overflow-x: auto;">
|
||||
{% csrf_token %}
|
||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
|
||||
<thead>
|
||||
|
||||
216
boxes/templates/boxes/box_management.html
Normal file
216
boxes/templates/boxes/box_management.html
Normal file
@@ -0,0 +1,216 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Box Management - LabHelper{% endblock %}
|
||||
|
||||
{% block page_header %}
|
||||
<div class="page-header">
|
||||
<h1><i class="fas fa-boxes"></i> Box Management</h1>
|
||||
<p class="breadcrumb">
|
||||
<a href="/"><i class="fas fa-home"></i> Home</a> /
|
||||
Box Management
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="section">
|
||||
<h2><i class="fas fa-cube"></i> Box Types</h2>
|
||||
|
||||
<form method="post" action="{% url 'add_box_type' %}" style="margin-bottom: 30px; padding: 20px; background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); border-radius: 10px;">
|
||||
{% csrf_token %}
|
||||
<h3 style="margin-bottom: 15px; color: #667eea; font-size: 18px;">Add New Box Type</h3>
|
||||
<div style="display: flex; gap: 15px; flex-wrap: wrap; align-items: flex-end;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 8px; font-size: 14px;">Name</label>
|
||||
{{ box_type_form.name }}
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 8px; font-size: 14px;">Width (mm)</label>
|
||||
{{ box_type_form.width }}
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 8px; font-size: 14px;">Height (mm)</label>
|
||||
{{ box_type_form.height }}
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 8px; font-size: 14px;">Length (mm)</label>
|
||||
{{ box_type_form.length }}
|
||||
</div>
|
||||
<button type="submit" class="btn">
|
||||
<i class="fas fa-plus"></i> Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px;">
|
||||
{% for box_type in box_types %}
|
||||
<div class="box-type-card" style="background: white; padding: 20px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); border: 2px solid #f0f0f0; transition: all 0.3s;" id="box-type-card-{{ box_type.id }}">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0; color: #667eea; font-size: 20px; font-weight: 700;" id="box-type-name-{{ box_type.id }}">{{ box_type.name }}</h3>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button onclick="toggleEditBoxType({{ box_type.id }})" id="edit-btn-{{ box_type.id }}" style="background: none; border: none; cursor: pointer; color: #667eea; padding: 5px; transition: all 0.3s;" onmouseover="this.style.color='#764ba2'" onmouseout="this.style.color='#667eea'">
|
||||
<i class="fas fa-edit" style="font-size: 18px;"></i>
|
||||
</button>
|
||||
{% if not box_type.boxes.exists %}
|
||||
<form method="post" action="{% url 'delete_box_type' box_type.id %}" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this box type?');">
|
||||
{% csrf_token %}
|
||||
<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-trash" style="font-size: 18px;"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="box-type-view-{{ box_type.id }}" style="color: #666; font-size: 14px; line-height: 1.6;">
|
||||
<p style="margin: 5px 0;"><i class="fas fa-ruler-horizontal" style="width: 20px; color: #999;"></i> Width: <strong>{{ box_type.width }} mm</strong></p>
|
||||
<p style="margin: 5px 0;"><i class="fas fa-ruler-vertical" style="width: 20px; color: #999;"></i> Height: <strong>{{ box_type.height }} mm</strong></p>
|
||||
<p style="margin: 5px 0;"><i class="fas fa-arrows-alt-h" style="width: 20px; color: #999;"></i> Length: <strong>{{ box_type.length }} mm</strong></p>
|
||||
</div>
|
||||
<form id="box-type-edit-{{ box_type.id }}" method="post" action="{% url 'edit_box_type' box_type.id %}" style="display: none;" onsubmit="return confirm('Save changes?');">
|
||||
{% csrf_token %}
|
||||
<div style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 5px; font-size: 12px;">Name</label>
|
||||
<input type="text" name="name" value="{{ box_type.name }}" style="width: 100%; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<div style="flex: 1;">
|
||||
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 5px; font-size: 12px;">Width</label>
|
||||
<input type="number" name="width" value="{{ box_type.width }}" style="width: 100%; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 5px; font-size: 12px;">Height</label>
|
||||
<input type="number" name="height" value="{{ box_type.height }}" style="width: 100%; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 5px; font-size: 12px;">Length</label>
|
||||
<input type="number" name="length" value="{{ box_type.length }}" style="width: 100%; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm" style="width: 100%;">
|
||||
<i class="fas fa-save"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #e0e0e0; color: #888; font-size: 13px;">
|
||||
<i class="fas fa-box"></i> {{ box_type.boxes.count }} box{{ box_type.boxes.count|pluralize:"es" }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2><i class="fas fa-box"></i> Boxes</h2>
|
||||
|
||||
<form method="post" action="{% url 'add_box' %}" style="margin-bottom: 30px; padding: 20px; background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); border-radius: 10px;">
|
||||
{% csrf_token %}
|
||||
<h3 style="margin-bottom: 15px; color: #667eea; font-size: 18px;">Add New Box</h3>
|
||||
<div style="display: flex; gap: 15px; flex-wrap: wrap; align-items: flex-end;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 8px; font-size: 14px;">Box ID</label>
|
||||
{{ box_form.id }}
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 8px; font-size: 14px;">Box Type</label>
|
||||
{{ box_form.box_type }}
|
||||
</div>
|
||||
<button type="submit" class="btn">
|
||||
<i class="fas fa-plus"></i> Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px;">
|
||||
{% for box in boxes %}
|
||||
<div class="box-card" style="background: white; padding: 20px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); border: 2px solid #f0f0f0; transition: all 0.3s;" id="box-card-{{ box.id }}">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0; color: #667eea; font-size: 24px; font-weight: 700;" id="box-id-{{ box.id }}">{{ box.id }}</h3>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button onclick="toggleEditBox('{{ box.id }}')" id="edit-box-btn-{{ box.id }}" style="background: none; border: none; cursor: pointer; color: #667eea; padding: 5px; transition: all 0.3s;" onmouseover="this.style.color='#764ba2'" onmouseout="this.style.color='#667eea'">
|
||||
<i class="fas fa-edit" style="font-size: 18px;"></i>
|
||||
</button>
|
||||
{% if not box.things.exists %}
|
||||
<form method="post" action="{% url 'delete_box' box.id %}" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this box?');">
|
||||
{% csrf_token %}
|
||||
<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-trash" style="font-size: 18px;"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="box-view-{{ box.id }}" style="color: #666; font-size: 14px; margin-bottom: 15px;">
|
||||
<p style="margin: 5px 0;"><i class="fas fa-cube" style="width: 20px; color: #999;"></i> Type: <strong>{{ box.box_type.name }}</strong></p>
|
||||
</div>
|
||||
<form id="box-edit-{{ box.id }}" method="post" action="{% url 'edit_box' box.id %}" style="display: none;" onsubmit="return confirm('Save changes?');">
|
||||
{% csrf_token %}
|
||||
<div style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px;">
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 5px; font-size: 12px;">Box ID</label>
|
||||
<input type="text" name="id" value="{{ box.id }}" style="width: 100%; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 5px; font-size: 12px;">Box Type</label>
|
||||
<select name="box_type" style="width: 100%; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
|
||||
{% for type in box_types %}
|
||||
<option value="{{ type.id }}" {% if type.id == box.box_type.id %}selected{% endif %}>{{ type.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm" style="width: 100%;">
|
||||
<i class="fas fa-save"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div style="padding-top: 15px; border-top: 1px solid #e0e0e0;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<a href="{% url 'box_detail' box.id %}" class="btn btn-sm">
|
||||
<i class="fas fa-eye"></i> View Contents
|
||||
</a>
|
||||
<span style="color: #888; font-size: 13px;">
|
||||
<i class="fas fa-cube"></i> {{ box.things.count }} thing{{ box.things.count|pluralize }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function toggleEditBoxType(id) {
|
||||
var viewDiv = document.getElementById('box-type-view-' + id);
|
||||
var editForm = document.getElementById('box-type-edit-' + id);
|
||||
var editBtn = document.getElementById('edit-btn-' + id);
|
||||
|
||||
if (editForm.style.display === 'none') {
|
||||
viewDiv.style.display = 'none';
|
||||
editForm.style.display = 'block';
|
||||
editBtn.innerHTML = '<i class="fas fa-times" style="font-size: 18px;"></i>';
|
||||
} else {
|
||||
viewDiv.style.display = 'block';
|
||||
editForm.style.display = 'none';
|
||||
editBtn.innerHTML = '<i class="fas fa-edit" style="font-size: 18px;"></i>';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEditBox(id) {
|
||||
var viewDiv = document.getElementById('box-view-' + id);
|
||||
var editForm = document.getElementById('box-edit-' + id);
|
||||
var editBtn = document.getElementById('edit-box-btn-' + id);
|
||||
|
||||
if (editForm.style.display === 'none') {
|
||||
viewDiv.style.display = 'none';
|
||||
editForm.style.display = 'block';
|
||||
editBtn.innerHTML = '<i class="fas fa-times" style="font-size: 18px;"></i>';
|
||||
} else {
|
||||
viewDiv.style.display = 'block';
|
||||
editForm.style.display = 'none';
|
||||
editBtn.innerHTML = '<i class="fas fa-edit" style="font-size: 18px;"></i>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% load mptt_tags %}
|
||||
{% load dict_extras %}
|
||||
|
||||
{% block title %}LabHelper - Home{% endblock %}
|
||||
|
||||
@@ -50,17 +51,19 @@
|
||||
<li style="padding: 8px 0;">
|
||||
<div class="tree-item" style="display: flex; align-items: center; gap: 8px;">
|
||||
{% if children %}
|
||||
<span class="toggle-handle" style="display: inline-block; width: 24px; color: #667eea; font-weight: bold; cursor: pointer; transition: transform 0.2s;">[-]</span>
|
||||
<span class="toggle-handle" style="display: inline-block; width: 24px; color: #667eea; font-weight: bold; cursor: pointer; transition: transform 0.2s;">[+]</span>
|
||||
{% else %}
|
||||
<span class="toggle-handle" style="display: inline-block; width: 24px; color: #ccc;"> </span>
|
||||
{% endif %}
|
||||
<a href="{% url 'thing_type_detail' node.pk %}" style="color: #667eea; text-decoration: none; font-size: 16px; font-weight: 500; transition: color 0.2s;">{{ node.name }}</a>
|
||||
{% if node.things.exists %}
|
||||
<span style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600;">{{ node.things.count }}</span>
|
||||
{% with count=type_counts|get_item:node.pk %}
|
||||
{% if count and count > 0 %}
|
||||
<span style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600;">{{ count }}</span>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% if children %}
|
||||
<ul style="list-style: none; padding-left: 32px;">
|
||||
<ul style="list-style: none; padding-left: 32px; display: none;">
|
||||
{{ children }}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
@@ -17,19 +17,37 @@
|
||||
{% block content %}
|
||||
<div class="section">
|
||||
<div class="thing-card" style="display: flex; gap: 40px; flex-wrap: wrap;">
|
||||
<div class="thing-image" style="flex-shrink: 0;">
|
||||
<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: 400px; height: 400px; object-fit: cover; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);">
|
||||
<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: 400px; 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="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;">
|
||||
@@ -62,12 +80,117 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% 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 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;">
|
||||
|
||||
0
boxes/templatetags/__init__.py
Normal file
0
boxes/templatetags/__init__.py
Normal file
7
boxes/templatetags/dict_extras.py
Normal file
7
boxes/templatetags/dict_extras.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.filter
|
||||
def get_item(dictionary, key):
|
||||
return dictionary.get(key)
|
||||
972
boxes/tests.py
972
boxes/tests.py
File diff suppressed because it is too large
Load Diff
217
boxes/views.py
217
boxes/views.py
@@ -1,20 +1,39 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
|
||||
from .forms import ThingFormSet
|
||||
from .models import Box, Thing, ThingType
|
||||
from .forms import (
|
||||
BoxForm,
|
||||
BoxTypeForm,
|
||||
ThingFileForm,
|
||||
ThingFormSet,
|
||||
ThingLinkForm,
|
||||
ThingPictureForm,
|
||||
)
|
||||
from .models import Box, BoxType, Thing, ThingFile, ThingLink, ThingType
|
||||
|
||||
|
||||
@login_required
|
||||
def index(request):
|
||||
"""Home page with boxes and thing types."""
|
||||
boxes = Box.objects.select_related('box_type').all().order_by('id')
|
||||
thing_types = ThingType.objects.all()
|
||||
|
||||
type_counts = {}
|
||||
for thing_type in thing_types:
|
||||
descendants = thing_type.get_descendants(include_self=True)
|
||||
count = Thing.objects.filter(thing_type__in=descendants).count()
|
||||
type_counts[thing_type.pk] = count
|
||||
|
||||
return render(request, 'boxes/index.html', {
|
||||
'boxes': boxes,
|
||||
'thing_types': thing_types,
|
||||
'type_counts': type_counts,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def box_detail(request, box_id):
|
||||
"""Display contents of a box."""
|
||||
box = get_object_or_404(Box, pk=box_id)
|
||||
@@ -25,34 +44,96 @@ def box_detail(request, box_id):
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def thing_detail(request, thing_id):
|
||||
"""Display details of a thing."""
|
||||
thing = get_object_or_404(
|
||||
Thing.objects.select_related('thing_type', 'box', 'box__box_type'),
|
||||
Thing.objects.select_related('thing_type', 'box', 'box__box_type').prefetch_related('files', 'links'),
|
||||
pk=thing_id
|
||||
)
|
||||
|
||||
|
||||
boxes = Box.objects.select_related('box_type').all().order_by('id')
|
||||
|
||||
picture_form = ThingPictureForm(instance=thing)
|
||||
file_form = ThingFileForm()
|
||||
link_form = ThingLinkForm()
|
||||
|
||||
if request.method == 'POST':
|
||||
new_box_id = request.POST.get('new_box')
|
||||
if new_box_id:
|
||||
new_box = get_object_or_404(Box, pk=new_box_id)
|
||||
thing.box = new_box
|
||||
thing.save()
|
||||
action = request.POST.get('action')
|
||||
|
||||
if action == 'move':
|
||||
new_box_id = request.POST.get('new_box')
|
||||
if new_box_id:
|
||||
new_box = get_object_or_404(Box, pk=new_box_id)
|
||||
thing.box = new_box
|
||||
thing.save()
|
||||
return redirect('thing_detail', thing_id=thing.id)
|
||||
|
||||
elif action == 'upload_picture':
|
||||
picture_form = ThingPictureForm(request.POST, request.FILES, instance=thing)
|
||||
if picture_form.is_valid():
|
||||
picture_form.save()
|
||||
return redirect('thing_detail', thing_id=thing.id)
|
||||
|
||||
elif action == 'delete_picture':
|
||||
if thing.picture:
|
||||
thing.picture.delete()
|
||||
thing.picture = None
|
||||
thing.save()
|
||||
return redirect('thing_detail', thing_id=thing.id)
|
||||
|
||||
|
||||
elif action == 'add_file':
|
||||
file_form = ThingFileForm(request.POST, request.FILES)
|
||||
if file_form.is_valid():
|
||||
thing_file = file_form.save(commit=False)
|
||||
thing_file.thing = thing
|
||||
thing_file.save()
|
||||
return redirect('thing_detail', thing_id=thing.id)
|
||||
|
||||
elif action == 'add_link':
|
||||
link_form = ThingLinkForm(request.POST)
|
||||
if link_form.is_valid():
|
||||
thing_link = link_form.save(commit=False)
|
||||
thing_link.thing = thing
|
||||
thing_link.save()
|
||||
return redirect('thing_detail', thing_id=thing.id)
|
||||
|
||||
elif action == 'delete_file':
|
||||
file_id = request.POST.get('file_id')
|
||||
if file_id:
|
||||
try:
|
||||
thing_file = ThingFile.objects.get(pk=file_id, thing=thing)
|
||||
thing_file.file.delete()
|
||||
thing_file.delete()
|
||||
except ThingFile.DoesNotExist:
|
||||
pass
|
||||
return redirect('thing_detail', thing_id=thing.id)
|
||||
|
||||
elif action == 'delete_link':
|
||||
link_id = request.POST.get('link_id')
|
||||
if link_id:
|
||||
try:
|
||||
thing_link = ThingLink.objects.get(pk=link_id, thing=thing)
|
||||
thing_link.delete()
|
||||
except ThingLink.DoesNotExist:
|
||||
pass
|
||||
return redirect('thing_detail', thing_id=thing.id)
|
||||
|
||||
return render(request, 'boxes/thing_detail.html', {
|
||||
'thing': thing,
|
||||
'boxes': boxes,
|
||||
'picture_form': picture_form,
|
||||
'file_form': file_form,
|
||||
'link_form': link_form,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def search(request):
|
||||
"""Search page for things."""
|
||||
return render(request, 'boxes/search.html')
|
||||
|
||||
|
||||
@login_required
|
||||
def search_api(request):
|
||||
"""AJAX endpoint for searching things."""
|
||||
query = request.GET.get('q', '').strip()
|
||||
@@ -60,8 +141,14 @@ def search_api(request):
|
||||
return JsonResponse({'results': []})
|
||||
|
||||
things = Thing.objects.filter(
|
||||
name__icontains=query
|
||||
).select_related('thing_type', 'box')[:50]
|
||||
Q(name__icontains=query) |
|
||||
Q(description__icontains=query) |
|
||||
Q(thing_type__name__icontains=query) |
|
||||
Q(files__title__icontains=query) |
|
||||
Q(files__file__icontains=query) |
|
||||
Q(links__title__icontains=query) |
|
||||
Q(links__url__icontains=query)
|
||||
).prefetch_related('files', 'links').select_related('thing_type', 'box').distinct()[:50]
|
||||
|
||||
results = [
|
||||
{
|
||||
@@ -70,12 +157,27 @@ def search_api(request):
|
||||
'type': thing.thing_type.name,
|
||||
'box': thing.box.id,
|
||||
'description': thing.description[:100] if thing.description else '',
|
||||
'files': [
|
||||
{
|
||||
'title': f.title,
|
||||
'filename': f.filename(),
|
||||
}
|
||||
for f in thing.files.all()
|
||||
],
|
||||
'links': [
|
||||
{
|
||||
'title': l.title,
|
||||
'url': l.url,
|
||||
}
|
||||
for l in thing.links.all()
|
||||
],
|
||||
}
|
||||
for thing in things
|
||||
]
|
||||
return JsonResponse({'results': results})
|
||||
|
||||
|
||||
@login_required
|
||||
def add_things(request, box_id):
|
||||
"""Add multiple things to a box at once."""
|
||||
box = get_object_or_404(Box, pk=box_id)
|
||||
@@ -83,7 +185,7 @@ def add_things(request, box_id):
|
||||
success_message = None
|
||||
|
||||
if request.method == 'POST':
|
||||
formset = ThingFormSet(request.POST, queryset=Thing.objects.filter(box=box))
|
||||
formset = ThingFormSet(request.POST, request.FILES, queryset=Thing.objects.filter(box=box))
|
||||
|
||||
if formset.is_valid():
|
||||
things = formset.save(commit=False)
|
||||
@@ -106,19 +208,100 @@ def add_things(request, box_id):
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def thing_type_detail(request, type_id):
|
||||
"""Display details of a thing type with its hierarchy and things."""
|
||||
thing_type = get_object_or_404(ThingType, pk=type_id)
|
||||
|
||||
|
||||
descendants = thing_type.get_descendants(include_self=True)
|
||||
things_by_type = {}
|
||||
|
||||
|
||||
for descendant in descendants:
|
||||
things = descendant.things.select_related('box', 'box__box_type').all()
|
||||
if things:
|
||||
things_by_type[descendant] = things
|
||||
|
||||
|
||||
return render(request, 'boxes/thing_type_detail.html', {
|
||||
'thing_type': thing_type,
|
||||
'things_by_type': things_by_type,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def box_management(request):
|
||||
"""Main page for managing boxes and box types."""
|
||||
box_types = BoxType.objects.all().prefetch_related('boxes')
|
||||
boxes = Box.objects.select_related('box_type').all().prefetch_related('things')
|
||||
box_type_form = BoxTypeForm()
|
||||
box_form = BoxForm()
|
||||
|
||||
return render(request, 'boxes/box_management.html', {
|
||||
'box_types': box_types,
|
||||
'boxes': boxes,
|
||||
'box_type_form': box_type_form,
|
||||
'box_form': box_form,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def add_box_type(request):
|
||||
"""Add a new box type."""
|
||||
if request.method == 'POST':
|
||||
form = BoxTypeForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect('box_management')
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_box_type(request, type_id):
|
||||
"""Edit an existing box type."""
|
||||
box_type = get_object_or_404(BoxType, pk=type_id)
|
||||
if request.method == 'POST':
|
||||
form = BoxTypeForm(request.POST, instance=box_type)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect('box_management')
|
||||
|
||||
|
||||
@login_required
|
||||
def delete_box_type(request, type_id):
|
||||
"""Delete a box type."""
|
||||
box_type = get_object_or_404(BoxType, pk=type_id)
|
||||
if request.method == 'POST':
|
||||
if box_type.boxes.exists():
|
||||
return redirect('box_management')
|
||||
box_type.delete()
|
||||
return redirect('box_management')
|
||||
|
||||
|
||||
@login_required
|
||||
def add_box(request):
|
||||
"""Add a new box."""
|
||||
if request.method == 'POST':
|
||||
form = BoxForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect('box_management')
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_box(request, box_id):
|
||||
"""Edit an existing box."""
|
||||
box = get_object_or_404(Box, pk=box_id)
|
||||
if request.method == 'POST':
|
||||
form = BoxForm(request.POST, instance=box)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect('box_management')
|
||||
|
||||
|
||||
@login_required
|
||||
def delete_box(request, box_id):
|
||||
"""Delete a box."""
|
||||
box = get_object_or_404(Box, pk=box_id)
|
||||
if request.method == 'POST':
|
||||
if box.things.exists():
|
||||
return redirect('box_management')
|
||||
box.delete()
|
||||
return redirect('box_management')
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 775 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB |
0
labhelper/management/__init__.py
Normal file
0
labhelper/management/__init__.py
Normal file
0
labhelper/management/commands/__init__.py
Normal file
0
labhelper/management/commands/__init__.py
Normal file
60
labhelper/management/commands/create_default_users.py
Normal file
60
labhelper/management/commands/create_default_users.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Create default users and groups for LabHelper'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write('Creating default users and groups...')
|
||||
|
||||
groups = {
|
||||
'Lab Administrators': 'Full access to all lab functions',
|
||||
'Lab Staff': 'Can view and search items, add things to boxes',
|
||||
'Lab Viewers': 'Read-only access to view and search',
|
||||
}
|
||||
|
||||
for group_name, description in groups.items():
|
||||
group, created = Group.objects.get_or_create(name=group_name)
|
||||
if created:
|
||||
self.stdout.write(self.style.SUCCESS(f'Created group: {group_name}'))
|
||||
else:
|
||||
self.stdout.write(f'Group already exists: {group_name}')
|
||||
|
||||
users = {
|
||||
'admin': ('Lab Administrators', True),
|
||||
'staff': ('Lab Staff', False),
|
||||
'viewer': ('Lab Viewers', False),
|
||||
}
|
||||
|
||||
for username, (group_name, is_superuser) in users.items():
|
||||
if User.objects.filter(username=username).exists():
|
||||
self.stdout.write(f'User already exists: {username}')
|
||||
continue
|
||||
|
||||
user = User.objects.create_user(
|
||||
username=username,
|
||||
email=f'{username}@labhelper.local',
|
||||
password=f'{username}123',
|
||||
is_superuser=is_superuser,
|
||||
is_staff=is_superuser,
|
||||
)
|
||||
|
||||
group = Group.objects.get(name=group_name)
|
||||
user.groups.add(group)
|
||||
|
||||
if is_superuser:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'Created superuser: {username} (password: {username}123)')
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'Created user: {username} (password: {username}123)')
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('\nDefault users and groups created successfully!'))
|
||||
self.stdout.write('\nLogin credentials:')
|
||||
self.stdout.write(' admin / admin123')
|
||||
self.stdout.write(' staff / staff123')
|
||||
self.stdout.write(' viewer / viewer123')
|
||||
self.stdout.write('\nPlease change these passwords after first login!')
|
||||
@@ -10,6 +10,7 @@ For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
@@ -20,7 +21,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = 'f0arjg8q3ut4iuqrguqfjaruf0eripIZZN3t1kymy8ugqnj$li2knhha0@gc5v8f3bge=$+gbybj2$jt28uqm'
|
||||
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'f0arjg8q3ut4iuqrguqfjaruf0eripIZZN3t1kymy8ugqnj$li2knhha0@gc5v8f3bge=$+gbybj2$jt28uqm')
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
@@ -130,3 +131,7 @@ MEDIA_ROOT = BASE_DIR / 'data' / 'media'
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
LOGIN_URL = 'login'
|
||||
LOGIN_REDIRECT_URL = 'index'
|
||||
LOGOUT_REDIRECT_URL = 'login'
|
||||
|
||||
@@ -69,7 +69,8 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.navbar-nav a:hover {
|
||||
.navbar-nav a:hover,
|
||||
.navbar-nav button:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
@@ -227,8 +228,19 @@
|
||||
</a>
|
||||
<div class="navbar-nav">
|
||||
<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="/admin/"><i class="fas fa-cog"></i> Admin</a>
|
||||
{% if user.is_authenticated %}
|
||||
<form method="post" action="{% url 'logout' %}" style="display: inline;">
|
||||
{% csrf_token %}
|
||||
<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-sign-out-alt"></i> Logout ({{ user.username }})
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a href="{% url 'login' %}"><i class="fas fa-sign-in-alt"></i> Login</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
78
labhelper/templates/login.html
Normal file
78
labhelper/templates/login.html
Normal file
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - LabHelper{% endblock %}
|
||||
|
||||
{% block page_header %}
|
||||
<div class="page-header">
|
||||
<h1><i class="fas fa-sign-in-alt"></i> Login</h1>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="section" style="max-width: 500px; margin: 0 auto;">
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-error">
|
||||
<i class="fas fa-exclamation-circle"></i> Your username and password didn't match. Please try again.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if next %}
|
||||
{% if user.is_authenticated %}
|
||||
<div class="alert alert-error">
|
||||
<i class="fas fa-info-circle"></i> Your account doesn't have access to this page. To proceed,
|
||||
please login with an account that has access.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-error">
|
||||
<i class="fas fa-info-circle"></i> Please login to see this page.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'login' %}" style="display: flex; flex-direction: column; gap: 20px;">
|
||||
{% csrf_token %}
|
||||
|
||||
<div>
|
||||
<label for="{{ form.username.id_for_label }}" style="display: block; font-weight: 600; margin-bottom: 8px; color: #555;">
|
||||
<i class="fas fa-user"></i> Username
|
||||
</label>
|
||||
<input type="{{ form.username.field.widget.input_type }}"
|
||||
name="{{ form.username.name }}"
|
||||
id="{{ form.username.id_for_label }}"
|
||||
{% if form.username.value %}value="{{ form.username.value }}"{% endif %}
|
||||
required
|
||||
autofocus
|
||||
style="width: 100%; padding: 12px 16px; border: 2px solid #e0e0e0; border-radius: 10px; font-size: 15px; transition: all 0.3s;">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="{{ form.password.id_for_label }}" style="display: block; font-weight: 600; margin-bottom: 8px; color: #555;">
|
||||
<i class="fas fa-lock"></i> Password
|
||||
</label>
|
||||
<input type="password"
|
||||
name="{{ form.password.name }}"
|
||||
id="{{ form.password.id_for_label }}"
|
||||
required
|
||||
style="width: 100%; padding: 12px 16px; border: 2px solid #e0e0e0; border-radius: 10px; font-size: 15px; transition: all 0.3s;">
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
|
||||
<button type="submit" class="btn" style="justify-content: center; margin-top: 10px;">
|
||||
<i class="fas fa-sign-in-alt"></i> Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$('input[type="text"], input[type="password"]').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 %}
|
||||
@@ -18,11 +18,36 @@ from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from django.contrib.auth import views as auth_views
|
||||
|
||||
from boxes.views import add_things, box_detail, index, search, search_api, thing_detail, thing_type_detail
|
||||
from boxes.views import (
|
||||
add_box,
|
||||
add_box_type,
|
||||
add_things,
|
||||
box_detail,
|
||||
box_management,
|
||||
delete_box,
|
||||
delete_box_type,
|
||||
edit_box,
|
||||
edit_box_type,
|
||||
index,
|
||||
search,
|
||||
search_api,
|
||||
thing_detail,
|
||||
thing_type_detail,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', auth_views.LoginView.as_view(template_name='login.html'), name='login'),
|
||||
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
||||
path('', index, name='index'),
|
||||
path('box-management/', box_management, name='box_management'),
|
||||
path('box-type/add/', add_box_type, name='add_box_type'),
|
||||
path('box-type/<int:type_id>/edit/', edit_box_type, name='edit_box_type'),
|
||||
path('box-type/<int:type_id>/delete/', delete_box_type, name='delete_box_type'),
|
||||
path('box/add/', add_box, name='add_box'),
|
||||
path('box/<str:box_id>/edit/', edit_box, name='edit_box'),
|
||||
path('box/<str:box_id>/delete/', delete_box, name='delete_box'),
|
||||
path('box/<str:box_id>/', box_detail, name='box_detail'),
|
||||
path('thing/<int:thing_id>/', thing_detail, name='thing_detail'),
|
||||
path('thing-type/<int:type_id>/', thing_type_detail, name='thing_type_detail'),
|
||||
|
||||
45
scripts/deploy_secret.sh
Executable file
45
scripts/deploy_secret.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# Generate and deploy Django secret key to Kubernetes
|
||||
|
||||
NAMESPACE="labhelper"
|
||||
SECRET_NAME="django-secret"
|
||||
SECRET_FILE="argocd/secret.yaml"
|
||||
|
||||
# Check if secret file exists
|
||||
if [ ! -f "$SECRET_FILE" ]; then
|
||||
echo "Error: $SECRET_FILE not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Generate random secret key
|
||||
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(50))")
|
||||
|
||||
# Create temporary secret file with generated key
|
||||
TEMP_SECRET_FILE=$(mktemp)
|
||||
cat "$SECRET_FILE" | sed "s/CHANGE_ME_TO_RANDOM_STRING/$SECRET_KEY/g" > "$TEMP_SECRET_FILE"
|
||||
|
||||
# Check if secret already exists
|
||||
if kubectl get secret "$SECRET_NAME" -n "$NAMESPACE" &>/dev/null; then
|
||||
echo "Secret $SECRET_NAME already exists in namespace $NAMESPACE"
|
||||
read -p "Do you want to replace it? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted"
|
||||
rm "$TEMP_SECRET_FILE"
|
||||
exit 0
|
||||
fi
|
||||
kubectl apply -f "$TEMP_SECRET_FILE"
|
||||
echo "Secret updated successfully"
|
||||
else
|
||||
kubectl apply -f "$TEMP_SECRET_FILE"
|
||||
echo "Secret created successfully"
|
||||
fi
|
||||
|
||||
# Clean up
|
||||
rm "$TEMP_SECRET_FILE"
|
||||
|
||||
echo ""
|
||||
echo "Secret deployed:"
|
||||
echo " Name: $SECRET_NAME"
|
||||
echo " Namespace: $NAMESPACE"
|
||||
echo " Key: secret-key"
|
||||
45
scripts/full_deploy.sh
Executable file
45
scripts/full_deploy.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# Full deployment script - bumps both container versions by 0.001 and copies database
|
||||
|
||||
DEPLOYMENT_FILE="argocd/deployment.yaml"
|
||||
DB_SOURCE="data/db.sqlite3"
|
||||
DB_DEST="data-loader/preload.sqlite3"
|
||||
|
||||
# Check if deployment file exists
|
||||
if [ ! -f "$DEPLOYMENT_FILE" ]; then
|
||||
echo "Error: $DEPLOYMENT_FILE not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if source database exists
|
||||
if [ ! -f "$DB_SOURCE" ]; then
|
||||
echo "Error: $DB_SOURCE not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract current version of data-loader
|
||||
LOADER_VERSION=$(grep -E "image: git.baumann.gr/adebaumann/labhelper-data-loader:[0-9]" "$DEPLOYMENT_FILE" | sed -E 's/.*:([0-9.]+)/\1/')
|
||||
|
||||
# Extract current version of main container
|
||||
MAIN_VERSION=$(grep -E "image: git.baumann.gr/adebaumann/labhelper:[0-9]" "$DEPLOYMENT_FILE" | grep -v "data-loader" | sed -E 's/.*:([0-9.]+)/\1/')
|
||||
|
||||
if [ -z "$LOADER_VERSION" ] || [ -z "$MAIN_VERSION" ]; then
|
||||
echo "Error: Could not find current versions"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Calculate new versions (add 0.001), preserve leading zero
|
||||
NEW_LOADER_VERSION=$(echo "$LOADER_VERSION + 0.001" | bc | sed 's/^\./0./')
|
||||
NEW_MAIN_VERSION=$(echo "$MAIN_VERSION + 0.001" | bc | sed 's/^\./0./')
|
||||
|
||||
# Update the deployment file
|
||||
sed -i "s|image: git.baumann.gr/adebaumann/labhelper-data-loader:$LOADER_VERSION|image: git.baumann.gr/adebaumann/labhelper-data-loader:$NEW_LOADER_VERSION|" "$DEPLOYMENT_FILE"
|
||||
sed -i "s|image: git.baumann.gr/adebaumann/labhelper:$MAIN_VERSION|image: git.baumann.gr/adebaumann/labhelper:$NEW_MAIN_VERSION|" "$DEPLOYMENT_FILE"
|
||||
|
||||
# Copy database
|
||||
cp "$DB_SOURCE" "$DB_DEST"
|
||||
|
||||
echo "Full deployment prepared:"
|
||||
echo " Data loader: $LOADER_VERSION -> $NEW_LOADER_VERSION"
|
||||
echo " Main container: $MAIN_VERSION -> $NEW_MAIN_VERSION"
|
||||
echo " Database copied to $DB_DEST"
|
||||
27
scripts/partial_deploy.sh
Executable file
27
scripts/partial_deploy.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# Partial deployment script - bumps main container version by 0.001
|
||||
|
||||
DEPLOYMENT_FILE="argocd/deployment.yaml"
|
||||
|
||||
# Check if file exists
|
||||
if [ ! -f "$DEPLOYMENT_FILE" ]; then
|
||||
echo "Error: $DEPLOYMENT_FILE not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract current version of main container (labhelper, not labhelper-data-loader)
|
||||
CURRENT_VERSION=$(grep -E "image: git.baumann.gr/adebaumann/labhelper:[0-9]" "$DEPLOYMENT_FILE" | grep -v "data-loader" | sed -E 's/.*:([0-9.]+)/\1/')
|
||||
|
||||
if [ -z "$CURRENT_VERSION" ]; then
|
||||
echo "Error: Could not find current version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Calculate new version (add 0.001), preserve leading zero
|
||||
NEW_VERSION=$(echo "$CURRENT_VERSION + 0.001" | bc | sed 's/^\./0./')
|
||||
|
||||
# Update the deployment file (only the main container, not the data-loader)
|
||||
sed -i "s|image: git.baumann.gr/adebaumann/labhelper:$CURRENT_VERSION|image: git.baumann.gr/adebaumann/labhelper:$NEW_VERSION|" "$DEPLOYMENT_FILE"
|
||||
|
||||
echo "Partial deployment prepared:"
|
||||
echo " Main container: $CURRENT_VERSION -> $NEW_VERSION"
|
||||
Reference in New Issue
Block a user