diff --git a/argocd/deployment.yaml b/argocd/deployment.yaml index 5678422..0ecd729 100644 --- a/argocd/deployment.yaml +++ b/argocd/deployment.yaml @@ -27,7 +27,7 @@ spec: mountPath: /data containers: - name: web - image: git.baumann.gr/adebaumann/labhelper:0.034 + image: git.baumann.gr/adebaumann/labhelper:0.035 imagePullPolicy: Always ports: - containerPort: 8000 diff --git a/boxes/tests.py b/boxes/tests.py index 6f865ef..c7c9ef9 100644 --- a/boxes/tests.py +++ b/boxes/tests.py @@ -1,4 +1,5 @@ from django.contrib.admin.sites import AdminSite +from django.contrib.auth.models import User from django.core.files.uploadedfile import SimpleUploadedFile from django.db import IntegrityError from django.test import Client, TestCase @@ -8,6 +9,19 @@ from .admin import BoxAdmin, BoxTypeAdmin, ThingAdmin, ThingTypeAdmin 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): """Tests for the BoxType model.""" @@ -340,13 +354,9 @@ class ThingAdminTests(TestCase): self.assertEqual(self.admin.search_fields, ('name', 'description')) -class IndexViewTests(TestCase): +class IndexViewTests(AuthTestCase): """Tests for the index view.""" - def setUp(self): - """Set up test client.""" - self.client = Client() - def test_index_returns_200(self): """Index page should return 200 status.""" response = self.client.get('/') @@ -362,13 +372,19 @@ class IndexViewTests(TestCase): response = self.client.get('/') 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.""" def setUp(self): """Set up test fixtures.""" - self.client = Client() + super().setUp() self.box_type = BoxType.objects.create( name='Standard Box', width=200, @@ -488,13 +504,19 @@ class BoxDetailViewTests(TestCase): url = reverse('box_detail', kwargs={'box_id': '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.""" def setUp(self): """Set up test fixtures.""" - self.client = Client() + super().setUp() self.box_type = BoxType.objects.create( name='Standard Box', width=200, @@ -554,14 +576,36 @@ class ThingDetailViewTests(TestCase): url = reverse('thing_detail', kwargs={'thing_id': 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.""" - def setUp(self): - """Set up test client.""" - self.client = Client() - def test_search_returns_200(self): """Search page should return 200 status.""" response = self.client.get('/search/') @@ -577,13 +621,19 @@ class SearchViewTests(TestCase): response = self.client.get('/search/') self.assertContains(response, 'id="results-container"') + def test_search_requires_login(self): + """Search page should redirect to login if not authenticated.""" + self.client.logout() + response = self.client.get('/search/') + self.assertRedirects(response, '/login/?next=/search/') -class SearchApiTests(TestCase): + +class SearchApiTests(AuthTestCase): """Tests for search API.""" def setUp(self): """Set up test fixtures.""" - self.client = Client() + super().setUp() self.box_type = BoxType.objects.create( name='Standard Box', width=200, @@ -664,13 +714,19 @@ class SearchApiTests(TestCase): self.assertEqual(results[0]['type'], 'Electronics') 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.""" def setUp(self): """Set up test fixtures.""" - self.client = Client() + super().setUp() self.box_type = BoxType.objects.create( name='Standard Box', width=200, @@ -704,17 +760,22 @@ class AddThingsViewTests(TestCase): self.assertContains(response, 'Picture') 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/', { 'form-TOTAL_FORMS': '3', + 'form-INITIAL_FORMS': '0', 'form-0-name': 'Arduino Uno', 'form-0-thing_type': self.thing_type.id, 'form-0-description': 'A microcontroller', 'form-1-name': 'LED Strip', 'form-1-thing_type': self.thing_type.id, 'form-1-description': 'Lighting component', + 'form-2-name': '', + 'form-2-thing_type': '', + 'form-2-description': '', }) - self.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) def test_add_things_post_required_name(self): @@ -728,28 +789,33 @@ class AddThingsViewTests(TestCase): self.assertContains(response, 'This field is required') 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/', { 'form-TOTAL_FORMS': '2', + 'form-INITIAL_FORMS': '0', 'form-0-name': 'Arduino Uno', 'form-0-thing_type': self.thing_type.id, + 'form-1-name': '', 'form-1-thing_type': self.thing_type.id, }) self.assertEqual(response.status_code, 200) self.assertContains(response, 'This field is required') - 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): - """Can create new thing types while adding things.""" + """Can add things with different thing types.""" new_type = ThingType.objects.create(name='Components') response = self.client.post(f'/box/{self.box.id}/add/', { 'form-TOTAL_FORMS': '2', + 'form-INITIAL_FORMS': '0', 'form-0-name': 'Resistor', 'form-0-thing_type': new_type.id, 'form-1-name': 'Capacitor', 'form-1-thing_type': new_type.id, }) - self.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.filter(thing_type=new_type).count(), 2) @@ -757,12 +823,13 @@ class AddThingsViewTests(TestCase): """Submitting empty forms should not create anything.""" response = self.client.post(f'/box/{self.box.id}/add/', { 'form-TOTAL_FORMS': '2', + 'form-INITIAL_FORMS': '0', 'form-0-name': '', - 'form-0-thing_type': self.thing_type.id, + 'form-0-thing_type': '', '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) def test_add_things_box_not_exists(self): @@ -775,6 +842,7 @@ class AddThingsViewTests(TestCase): self.thing_type_2 = ThingType.objects.create(name='Mechanical') response = self.client.post(f'/box/{self.box.id}/add/', { 'form-TOTAL_FORMS': '3', + 'form-INITIAL_FORMS': '0', 'form-0-name': 'Bolt', 'form-0-thing_type': self.thing_type_2.id, 'form-1-name': 'Nut', @@ -782,7 +850,139 @@ class AddThingsViewTests(TestCase): 'form-2-name': 'Washer', '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() + self.assertEqual(things.count(), 3) for thing in things: 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/') diff --git a/boxes/views.py b/boxes/views.py index 49727c8..481fc86 100644 --- a/boxes/views.py +++ b/boxes/views.py @@ -1,3 +1,4 @@ +from django.contrib.auth.decorators import login_required from django.http import HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect, render @@ -5,6 +6,7 @@ from .forms import ThingFormSet from .models import Box, Thing, ThingType +@login_required def index(request): """Home page with boxes and thing types.""" 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): """Display contents of a box.""" 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): """Display details of a thing.""" thing = get_object_or_404( @@ -48,11 +52,13 @@ def thing_detail(request, thing_id): }) +@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() @@ -76,6 +82,7 @@ def search_api(request): 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) @@ -106,6 +113,7 @@ 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) diff --git a/labhelper/management/__init__.py b/labhelper/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/labhelper/management/commands/__init__.py b/labhelper/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/labhelper/management/commands/create_default_users.py b/labhelper/management/commands/create_default_users.py new file mode 100644 index 0000000..eedd780 --- /dev/null +++ b/labhelper/management/commands/create_default_users.py @@ -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!') diff --git a/labhelper/settings.py b/labhelper/settings.py index eb02e7c..0829e59 100644 --- a/labhelper/settings.py +++ b/labhelper/settings.py @@ -130,3 +130,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' diff --git a/labhelper/templates/base.html b/labhelper/templates/base.html index 8f0702d..2d9ad59 100644 --- a/labhelper/templates/base.html +++ b/labhelper/templates/base.html @@ -229,6 +229,11 @@ Home Search Admin + {% if user.is_authenticated %} + Logout ({{ user.username }}) + {% else %} + Login + {% endif %} diff --git a/labhelper/templates/login.html b/labhelper/templates/login.html new file mode 100644 index 0000000..011dc9d --- /dev/null +++ b/labhelper/templates/login.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} + +{% block title %}Login - LabHelper{% endblock %} + +{% block page_header %} + +{% endblock %} + +{% block content %} +
+ {% if form.errors %} +
+ Your username and password didn't match. Please try again. +
+ {% endif %} + + {% if next %} + {% if user.is_authenticated %} +
+ Your account doesn't have access to this page. To proceed, + please login with an account that has access. +
+ {% else %} +
+ Please login to see this page. +
+ {% endif %} + {% endif %} + +
+ {% csrf_token %} + +
+ + +
+ +
+ + +
+ + + + +
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/labhelper/urls.py b/labhelper/urls.py index 5032423..1537b00 100644 --- a/labhelper/urls.py +++ b/labhelper/urls.py @@ -18,10 +18,13 @@ 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 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//', box_detail, name='box_detail'), path('thing//', thing_detail, name='thing_detail'), diff --git a/scripts/full_deploy.sh b/scripts/full_deploy.sh new file mode 100755 index 0000000..9b2885e --- /dev/null +++ b/scripts/full_deploy.sh @@ -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" diff --git a/scripts/partial_deploy.sh b/scripts/partial_deploy.sh new file mode 100755 index 0000000..27966fe --- /dev/null +++ b/scripts/partial_deploy.sh @@ -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"