Login added, tests completed
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 16s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 3s

This commit is contained in:
2025-12-30 00:26:19 +01:00
parent eb8284fdd2
commit e172e2f9dc
12 changed files with 459 additions and 29 deletions

View File

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

View File

@@ -1,4 +1,5 @@
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.db import IntegrityError from django.db import IntegrityError
from django.test import Client, TestCase from django.test import Client, TestCase
@@ -8,6 +9,19 @@ from .admin import BoxAdmin, BoxTypeAdmin, ThingAdmin, ThingTypeAdmin
from .models import Box, BoxType, Thing, ThingType from .models import Box, BoxType, Thing, ThingType
class AuthTestCase(TestCase):
"""Base test case that provides authenticated client."""
def setUp(self):
"""Set up test user and authenticated client."""
self.user = User.objects.create_user(
username='testuser',
password='testpass123'
)
self.client = Client()
self.client.login(username='testuser', password='testpass123')
class BoxTypeModelTests(TestCase): class BoxTypeModelTests(TestCase):
"""Tests for the BoxType model.""" """Tests for the BoxType model."""
@@ -340,13 +354,9 @@ class ThingAdminTests(TestCase):
self.assertEqual(self.admin.search_fields, ('name', 'description')) self.assertEqual(self.admin.search_fields, ('name', 'description'))
class IndexViewTests(TestCase): class IndexViewTests(AuthTestCase):
"""Tests for the index view.""" """Tests for the index view."""
def setUp(self):
"""Set up test client."""
self.client = Client()
def test_index_returns_200(self): def test_index_returns_200(self):
"""Index page should return 200 status.""" """Index page should return 200 status."""
response = self.client.get('/') response = self.client.get('/')
@@ -362,13 +372,19 @@ class IndexViewTests(TestCase):
response = self.client.get('/') response = self.client.get('/')
self.assertContains(response, '/admin/') self.assertContains(response, '/admin/')
def test_index_requires_login(self):
"""Index page should redirect to login if not authenticated."""
self.client.logout()
response = self.client.get('/')
self.assertRedirects(response, '/login/?next=/')
class BoxDetailViewTests(TestCase):
class BoxDetailViewTests(AuthTestCase):
"""Tests for the box detail view.""" """Tests for the box detail view."""
def setUp(self): def setUp(self):
"""Set up test fixtures.""" """Set up test fixtures."""
self.client = Client() super().setUp()
self.box_type = BoxType.objects.create( self.box_type = BoxType.objects.create(
name='Standard Box', name='Standard Box',
width=200, width=200,
@@ -488,13 +504,19 @@ class BoxDetailViewTests(TestCase):
url = reverse('box_detail', kwargs={'box_id': 'BOX001'}) url = reverse('box_detail', kwargs={'box_id': 'BOX001'})
self.assertEqual(url, '/box/BOX001/') self.assertEqual(url, '/box/BOX001/')
def test_box_detail_requires_login(self):
"""Box detail page should redirect to login if not authenticated."""
self.client.logout()
response = self.client.get(f'/box/{self.box.id}/')
self.assertRedirects(response, f'/login/?next=/box/{self.box.id}/')
class ThingDetailViewTests(TestCase):
class ThingDetailViewTests(AuthTestCase):
"""Tests for thing detail view.""" """Tests for thing detail view."""
def setUp(self): def setUp(self):
"""Set up test fixtures.""" """Set up test fixtures."""
self.client = Client() super().setUp()
self.box_type = BoxType.objects.create( self.box_type = BoxType.objects.create(
name='Standard Box', name='Standard Box',
width=200, width=200,
@@ -554,14 +576,36 @@ class ThingDetailViewTests(TestCase):
url = reverse('thing_detail', kwargs={'thing_id': 1}) url = reverse('thing_detail', kwargs={'thing_id': 1})
self.assertEqual(url, '/thing/1/') self.assertEqual(url, '/thing/1/')
def test_thing_detail_requires_login(self):
"""Thing detail page should redirect to login if not authenticated."""
self.client.logout()
response = self.client.get(f'/thing/{self.thing.id}/')
self.assertRedirects(response, f'/login/?next=/thing/{self.thing.id}/')
class SearchViewTests(TestCase): def test_thing_detail_move_to_box(self):
"""Thing can be moved to another box via POST."""
new_box = Box.objects.create(id='BOX002', box_type=self.box_type)
response = self.client.post(
f'/thing/{self.thing.id}/',
{'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}/')
self.assertContains(response, 'BOX001')
self.assertContains(response, 'BOX002')
self.assertContains(response, 'BOX003')
class SearchViewTests(AuthTestCase):
"""Tests for search view.""" """Tests for search view."""
def setUp(self):
"""Set up test client."""
self.client = Client()
def test_search_returns_200(self): def test_search_returns_200(self):
"""Search page should return 200 status.""" """Search page should return 200 status."""
response = self.client.get('/search/') response = self.client.get('/search/')
@@ -577,13 +621,19 @@ class SearchViewTests(TestCase):
response = self.client.get('/search/') response = self.client.get('/search/')
self.assertContains(response, 'id="results-container"') self.assertContains(response, 'id="results-container"')
def test_search_requires_login(self):
"""Search page should redirect to login if not authenticated."""
self.client.logout()
response = self.client.get('/search/')
self.assertRedirects(response, '/login/?next=/search/')
class SearchApiTests(TestCase):
class SearchApiTests(AuthTestCase):
"""Tests for search API.""" """Tests for search API."""
def setUp(self): def setUp(self):
"""Set up test fixtures.""" """Set up test fixtures."""
self.client = Client() super().setUp()
self.box_type = BoxType.objects.create( self.box_type = BoxType.objects.create(
name='Standard Box', name='Standard Box',
width=200, width=200,
@@ -664,13 +714,19 @@ class SearchApiTests(TestCase):
self.assertEqual(results[0]['type'], 'Electronics') self.assertEqual(results[0]['type'], 'Electronics')
self.assertEqual(results[0]['box'], 'BOX001') self.assertEqual(results[0]['box'], 'BOX001')
def test_search_api_requires_login(self):
"""Search API should redirect to login if not authenticated."""
self.client.logout()
response = self.client.get('/search/api/?q=test')
self.assertRedirects(response, '/login/?next=/search/api/%3Fq%3Dtest')
class AddThingsViewTests(TestCase):
class AddThingsViewTests(AuthTestCase):
"""Tests for add things view.""" """Tests for add things view."""
def setUp(self): def setUp(self):
"""Set up test fixtures.""" """Set up test fixtures."""
self.client = Client() super().setUp()
self.box_type = BoxType.objects.create( self.box_type = BoxType.objects.create(
name='Standard Box', name='Standard Box',
width=200, width=200,
@@ -704,17 +760,22 @@ class AddThingsViewTests(TestCase):
self.assertContains(response, 'Picture') self.assertContains(response, 'Picture')
def test_add_things_post_valid(self): def test_add_things_post_valid(self):
"""Adding valid things should redirect to box detail.""" """Adding valid things should show success message and create things."""
response = self.client.post(f'/box/{self.box.id}/add/', { response = self.client.post(f'/box/{self.box.id}/add/', {
'form-TOTAL_FORMS': '3', 'form-TOTAL_FORMS': '3',
'form-INITIAL_FORMS': '0',
'form-0-name': 'Arduino Uno', 'form-0-name': 'Arduino Uno',
'form-0-thing_type': self.thing_type.id, 'form-0-thing_type': self.thing_type.id,
'form-0-description': 'A microcontroller', 'form-0-description': 'A microcontroller',
'form-1-name': 'LED Strip', 'form-1-name': 'LED Strip',
'form-1-thing_type': self.thing_type.id, 'form-1-thing_type': self.thing_type.id,
'form-1-description': 'Lighting component', 'form-1-description': 'Lighting component',
'form-2-name': '',
'form-2-thing_type': '',
'form-2-description': '',
}) })
self.assertRedirects(response, f'/box/{self.box.id}/') self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Added 2 things successfully')
self.assertEqual(Thing.objects.count(), 2) self.assertEqual(Thing.objects.count(), 2)
def test_add_things_post_required_name(self): def test_add_things_post_required_name(self):
@@ -728,28 +789,33 @@ class AddThingsViewTests(TestCase):
self.assertContains(response, 'This field is required') self.assertContains(response, 'This field is required')
def test_add_things_post_partial_valid_invalid(self): def test_add_things_post_partial_valid_invalid(self):
"""Partial submission: one valid, one missing name.""" """Partial submission: one valid, one missing name - nothing saved due to formset validation."""
response = self.client.post(f'/box/{self.box.id}/add/', { response = self.client.post(f'/box/{self.box.id}/add/', {
'form-TOTAL_FORMS': '2', 'form-TOTAL_FORMS': '2',
'form-INITIAL_FORMS': '0',
'form-0-name': 'Arduino Uno', 'form-0-name': 'Arduino Uno',
'form-0-thing_type': self.thing_type.id, 'form-0-thing_type': self.thing_type.id,
'form-1-name': '',
'form-1-thing_type': self.thing_type.id, 'form-1-thing_type': self.thing_type.id,
}) })
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, 'This field is required') self.assertContains(response, 'This field is required')
self.assertEqual(Thing.objects.count(), 1) # Formset validation fails, so nothing is saved
self.assertEqual(Thing.objects.count(), 0)
def test_add_things_creates_thing_types(self): def test_add_things_creates_thing_types(self):
"""Can create new thing types while adding things.""" """Can add things with different thing types."""
new_type = ThingType.objects.create(name='Components') new_type = ThingType.objects.create(name='Components')
response = self.client.post(f'/box/{self.box.id}/add/', { response = self.client.post(f'/box/{self.box.id}/add/', {
'form-TOTAL_FORMS': '2', 'form-TOTAL_FORMS': '2',
'form-INITIAL_FORMS': '0',
'form-0-name': 'Resistor', 'form-0-name': 'Resistor',
'form-0-thing_type': new_type.id, 'form-0-thing_type': new_type.id,
'form-1-name': 'Capacitor', 'form-1-name': 'Capacitor',
'form-1-thing_type': new_type.id, 'form-1-thing_type': new_type.id,
}) })
self.assertRedirects(response, f'/box/{self.box.id}/') self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Added 2 things successfully')
self.assertEqual(Thing.objects.count(), 2) self.assertEqual(Thing.objects.count(), 2)
self.assertEqual(Thing.objects.filter(thing_type=new_type).count(), 2) self.assertEqual(Thing.objects.filter(thing_type=new_type).count(), 2)
@@ -757,12 +823,13 @@ class AddThingsViewTests(TestCase):
"""Submitting empty forms should not create anything.""" """Submitting empty forms should not create anything."""
response = self.client.post(f'/box/{self.box.id}/add/', { response = self.client.post(f'/box/{self.box.id}/add/', {
'form-TOTAL_FORMS': '2', 'form-TOTAL_FORMS': '2',
'form-INITIAL_FORMS': '0',
'form-0-name': '', 'form-0-name': '',
'form-0-thing_type': self.thing_type.id, 'form-0-thing_type': '',
'form-1-name': '', 'form-1-name': '',
'form-1-thing_type': self.thing_type.id, 'form-1-thing_type': '',
}) })
self.assertRedirects(response, f'/box/{self.box.id}/') self.assertEqual(response.status_code, 200)
self.assertEqual(Thing.objects.count(), 0) self.assertEqual(Thing.objects.count(), 0)
def test_add_things_box_not_exists(self): def test_add_things_box_not_exists(self):
@@ -775,6 +842,7 @@ class AddThingsViewTests(TestCase):
self.thing_type_2 = ThingType.objects.create(name='Mechanical') self.thing_type_2 = ThingType.objects.create(name='Mechanical')
response = self.client.post(f'/box/{self.box.id}/add/', { response = self.client.post(f'/box/{self.box.id}/add/', {
'form-TOTAL_FORMS': '3', 'form-TOTAL_FORMS': '3',
'form-INITIAL_FORMS': '0',
'form-0-name': 'Bolt', 'form-0-name': 'Bolt',
'form-0-thing_type': self.thing_type_2.id, 'form-0-thing_type': self.thing_type_2.id,
'form-1-name': 'Nut', 'form-1-name': 'Nut',
@@ -782,7 +850,139 @@ class AddThingsViewTests(TestCase):
'form-2-name': 'Washer', 'form-2-name': 'Washer',
'form-2-thing_type': self.thing_type_2.id, 'form-2-thing_type': self.thing_type_2.id,
}) })
self.assertRedirects(response, f'/box/{self.box.id}/') self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Added 3 things successfully')
things = Thing.objects.all() things = Thing.objects.all()
self.assertEqual(things.count(), 3)
for thing in things: for thing in things:
self.assertEqual(thing.box, self.box) self.assertEqual(thing.box, self.box)
def test_add_things_requires_login(self):
"""Add things page should redirect to login if not authenticated."""
self.client.logout()
response = self.client.get(f'/box/{self.box.id}/add/')
self.assertRedirects(response, f'/login/?next=/box/{self.box.id}/add/')
class ThingTypeDetailViewTests(AuthTestCase):
"""Tests for thing type detail view."""
def setUp(self):
"""Set up test fixtures."""
super().setUp()
self.box_type = BoxType.objects.create(
name='Standard Box',
width=200,
height=100,
length=300
)
self.box = Box.objects.create(
id='BOX001',
box_type=self.box_type
)
self.parent_type = ThingType.objects.create(name='Electronics')
self.child_type = ThingType.objects.create(
name='Microcontrollers',
parent=self.parent_type
)
self.thing = Thing.objects.create(
name='Arduino Uno',
thing_type=self.child_type,
box=self.box
)
def test_thing_type_detail_returns_200(self):
"""Thing type detail page should return 200 status."""
response = self.client.get(f'/thing-type/{self.parent_type.id}/')
self.assertEqual(response.status_code, 200)
def test_thing_type_detail_returns_404_for_invalid(self):
"""Thing type detail page should return 404 for non-existent type."""
response = self.client.get('/thing-type/99999/')
self.assertEqual(response.status_code, 404)
def test_thing_type_detail_shows_type_name(self):
"""Thing type detail page should show type name."""
response = self.client.get(f'/thing-type/{self.parent_type.id}/')
self.assertContains(response, 'Electronics')
def test_thing_type_detail_shows_descendants(self):
"""Thing type detail page should show descendant types."""
response = self.client.get(f'/thing-type/{self.parent_type.id}/')
self.assertContains(response, 'Microcontrollers')
def test_thing_type_detail_shows_things(self):
"""Thing type detail page should show things of this type."""
response = self.client.get(f'/thing-type/{self.parent_type.id}/')
self.assertContains(response, 'Arduino Uno')
def test_thing_type_detail_uses_correct_template(self):
"""Thing type detail page should use correct template."""
response = self.client.get(f'/thing-type/{self.parent_type.id}/')
self.assertTemplateUsed(response, 'boxes/thing_type_detail.html')
def test_thing_type_detail_url_name(self):
"""Thing type detail URL should be reversible by name."""
url = reverse('thing_type_detail', kwargs={'type_id': 1})
self.assertEqual(url, '/thing-type/1/')
def test_thing_type_detail_requires_login(self):
"""Thing type detail page should redirect to login if not authenticated."""
self.client.logout()
response = self.client.get(f'/thing-type/{self.parent_type.id}/')
self.assertRedirects(response, f'/login/?next=/thing-type/{self.parent_type.id}/')
class LoginViewTests(TestCase):
"""Tests for login view."""
def setUp(self):
"""Set up test fixtures."""
self.client = Client()
self.user = User.objects.create_user(
username='testuser',
password='testpass123'
)
def test_login_page_returns_200(self):
"""Login page should return 200 status."""
response = self.client.get('/login/')
self.assertEqual(response.status_code, 200)
def test_login_page_contains_form(self):
"""Login page should contain login form."""
response = self.client.get('/login/')
self.assertContains(response, 'Username')
self.assertContains(response, 'Password')
self.assertContains(response, 'Login')
def test_login_with_valid_credentials(self):
"""Login with valid credentials should redirect to home."""
response = self.client.post('/login/', {
'username': 'testuser',
'password': 'testpass123'
})
self.assertRedirects(response, '/')
def test_login_with_invalid_credentials(self):
"""Login with invalid credentials should show error."""
response = self.client.post('/login/', {
'username': 'testuser',
'password': 'wrongpassword'
})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'username and password')
def test_login_redirects_to_next(self):
"""Login should redirect to 'next' parameter after success."""
response = self.client.post('/login/?next=/search/', {
'username': 'testuser',
'password': 'testpass123'
})
self.assertRedirects(response, '/search/')
def test_logout_redirects_to_login(self):
"""Logout should redirect to login page."""
self.client.login(username='testuser', password='testpass123')
response = self.client.post('/logout/')
self.assertRedirects(response, '/login/')

View File

@@ -1,3 +1,4 @@
from django.contrib.auth.decorators import login_required
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
@@ -5,6 +6,7 @@ from .forms import ThingFormSet
from .models import Box, Thing, ThingType from .models import Box, Thing, ThingType
@login_required
def index(request): def index(request):
"""Home page with boxes and thing types.""" """Home page with boxes and thing types."""
boxes = Box.objects.select_related('box_type').all().order_by('id') boxes = Box.objects.select_related('box_type').all().order_by('id')
@@ -15,6 +17,7 @@ def index(request):
}) })
@login_required
def box_detail(request, box_id): def box_detail(request, box_id):
"""Display contents of a box.""" """Display contents of a box."""
box = get_object_or_404(Box, pk=box_id) box = get_object_or_404(Box, pk=box_id)
@@ -25,6 +28,7 @@ def box_detail(request, box_id):
}) })
@login_required
def thing_detail(request, thing_id): def thing_detail(request, thing_id):
"""Display details of a thing.""" """Display details of a thing."""
thing = get_object_or_404( thing = get_object_or_404(
@@ -48,11 +52,13 @@ def thing_detail(request, thing_id):
}) })
@login_required
def search(request): def search(request):
"""Search page for things.""" """Search page for things."""
return render(request, 'boxes/search.html') return render(request, 'boxes/search.html')
@login_required
def search_api(request): def search_api(request):
"""AJAX endpoint for searching things.""" """AJAX endpoint for searching things."""
query = request.GET.get('q', '').strip() query = request.GET.get('q', '').strip()
@@ -76,6 +82,7 @@ def search_api(request):
return JsonResponse({'results': results}) return JsonResponse({'results': results})
@login_required
def add_things(request, box_id): def add_things(request, box_id):
"""Add multiple things to a box at once.""" """Add multiple things to a box at once."""
box = get_object_or_404(Box, pk=box_id) box = get_object_or_404(Box, pk=box_id)
@@ -106,6 +113,7 @@ def add_things(request, box_id):
}) })
@login_required
def thing_type_detail(request, type_id): def thing_type_detail(request, type_id):
"""Display details of a thing type with its hierarchy and things.""" """Display details of a thing type with its hierarchy and things."""
thing_type = get_object_or_404(ThingType, pk=type_id) thing_type = get_object_or_404(ThingType, pk=type_id)

View File

View 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!')

View File

@@ -130,3 +130,7 @@ MEDIA_ROOT = BASE_DIR / 'data' / 'media'
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'index'
LOGOUT_REDIRECT_URL = 'login'

View File

@@ -229,6 +229,11 @@
<a href="/"><i class="fas fa-home"></i> Home</a> <a href="/"><i class="fas fa-home"></i> Home</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> <a href="/admin/"><i class="fas fa-cog"></i> Admin</a>
{% if user.is_authenticated %}
<a href="/logout/"><i class="fas fa-sign-out-alt"></i> Logout ({{ user.username }})</a>
{% else %}
<a href="/login/"><i class="fas fa-sign-in-alt"></i> Login</a>
{% endif %}
</div> </div>
</nav> </nav>

View 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 %}

View File

@@ -18,10 +18,13 @@ from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import path 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_things, box_detail, index, search, search_api, thing_detail, thing_type_detail
urlpatterns = [ 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('', index, name='index'),
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'),

45
scripts/full_deploy.sh Executable file
View 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
View 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"