-
Notifications
You must be signed in to change notification settings - Fork 21
Add machine access control feature #622
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
…feat/extending_api
…o feature/instructor-ui
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds a comprehensive machine access control feature to the membership management system.
Purpose: Enable instructors and administrators to manage which machines members can access, and provide access logging functionality.
Key Changes:
- Added
is_instructorrole andaccess_permissionsM2M relationship to users - Implemented permission-based and service-based access control for devices
- Created instructor tools page for managing member machine access
- Added access logs page with pagination
Reviewed changes
Copilot reviewed 29 out of 29 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| users/models/custom_user.py | Added is_instructor field and access_permissions M2M for granular access control |
| api/models.py | Added AccessPermission model, device types, and permission/service-based access rules |
| api/views.py | Enhanced access logic to support both permission and service-based authorization |
| www/views.py | Added instructor tools, member search, permission update, and access logs views |
| www/decorators.py | Added instructor_or_staff_member_required decorator for authorization |
| www/templates/www/machine_access_control.html | Frontend for managing member machine permissions |
| www/templates/www/user_access_logs.html | Paginated access logs display |
| www/tests.py | Added tests for instructor role access control |
| api/tests.py | Added tests for permission-based and hybrid access scenarios |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @login_required | ||
| @self_or_staff_member_required | ||
| def useraccesslogs(request, id): | ||
| """ | ||
| Show access logs related to a specific user. | ||
| """ | ||
| user = get_object_or_404(CustomUser, id=id) | ||
| card_ids = list(user.nfccard_set.values_list("cardid", flat=True)) | ||
| q = Q() | ||
| if user.phone: | ||
| q |= Q(method="phone", payload=user.phone) | ||
| if user.mxid: | ||
| q |= Q(method="mxid", payload=user.mxid) | ||
| if card_ids: | ||
| q |= Q(method="nfc", payload__in=card_ids) | ||
| q |= Q(nfccard__user=user) | Q(claimed_by=user) | ||
|
|
||
| logs = DeviceAccessLogEntry.objects.filter(q).order_by("-date") | ||
| paginator = Paginator(logs, 20) | ||
| page_number = request.GET.get("page") | ||
| page_obj = paginator.get_page(page_number) | ||
|
|
||
| return render( | ||
| request, | ||
| "www/user_access_logs.html", | ||
| { | ||
| "userdetails": user, | ||
| "page_obj": page_obj, | ||
| }, | ||
| ) |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing test coverage for the useraccesslogs view which displays paginated access logs. Consider adding tests to verify pagination, query filtering, and access control for this new feature.
www/views.py
Outdated
| allowed = list(user.access_permissions.values_list("id", flat=True)) | ||
|
|
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The allowed variable is defined at line 260 but then redefined at line 267 with the same value. The first assignment should be removed as it's redundant.
| allowed = list(user.access_permissions.values_list("id", flat=True)) |
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/x-www-form-urlencoded", | ||
| "X-CSRFToken": "{{ csrf_token }}" |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The CSRF token is embedded directly in the template without proper escaping context. While Django templates auto-escape by default, CSRF tokens in JavaScript should be retrieved from the CSRF cookie or a meta tag to avoid potential issues. Consider using getCookie('csrftoken') or adding a meta tag instead.
| @login_required | ||
| @instructor_or_staff_member_required | ||
| def update_permission(request): | ||
| """ | ||
| Updates a user’s machine access permissions when a checkbox is toggled in the UI. | ||
| """ | ||
| user_id = request.POST.get("user_id") | ||
| device_id = request.POST.get("machine_id") | ||
| checked = request.POST.get("checked") == "true" | ||
|
|
||
| user = get_object_or_404(CustomUser, id=user_id) | ||
| device = get_object_or_404(AccessDevice, id=device_id) | ||
|
|
||
| # All permissions granted by this device | ||
| perms = device.allowed_permissions.all() | ||
|
|
||
| if checked: | ||
| user.access_permissions.add(*perms) | ||
| else: | ||
| user.access_permissions.remove(*perms) | ||
|
|
||
| return JsonResponse({"success": True}) |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The update_permission view lacks validation to ensure that the requesting user has permission to modify the target user's permissions. While the decorator checks for instructor/staff status, there's no check to prevent privilege escalation (e.g., instructors potentially granting permissions they don't have authority over). Consider adding authorization checks.
| fetch(`/www/machine-access-control/search/?member_number=${num}&last_name=${last}`) | ||
| .then(r => r.json()) | ||
| .then(data => { | ||
| if (!data.found) { | ||
| document.getElementById("search_result").innerHTML = | ||
| "<div class='alert alert-danger'>Member not found.</div>"; | ||
| disableAll(); | ||
| return; | ||
| } | ||
|
|
||
| selectedUserId = data.user_id; | ||
| document.getElementById("search_result").innerHTML = | ||
| `<div class='alert alert-success'> | ||
| Member found: <b>${data.first_name} ${data.last_name}</b> – permissions (changes are saved automatically when you click a checkbox): | ||
| </div>`; | ||
|
|
||
| const userPermissions = data.allowed_permissions; | ||
|
|
||
| // Enable checkboxes and mark them according to user's permissions | ||
| document.querySelectorAll(".perm-check").forEach(c => { | ||
| c.disabled = false; | ||
| const devicePerms = c.dataset.permissions.split(",").filter(x => x.trim().length > 0).map(Number); | ||
| c.checked = devicePerms.every(p => userPermissions.includes(p)); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| // Send updates when checkbox changes | ||
| document.querySelectorAll(".perm-check").forEach(cb => { | ||
| cb.addEventListener("change", function() { | ||
| if (!selectedUserId) return; | ||
|
|
||
| // Create POST data. | ||
| const formData = new URLSearchParams(); | ||
| formData.append("user_id", selectedUserId); | ||
| formData.append("machine_id", this.dataset.machine); | ||
| formData.append("checked", this.checked); | ||
|
|
||
| // Send a POST request to the server. | ||
| fetch("/www/machine-access-control/update/", { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/x-www-form-urlencoded", | ||
| "X-CSRFToken": "{{ csrf_token }}" | ||
| }, | ||
| body: formData.toString() | ||
| }); | ||
| }); |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The JavaScript doesn't handle errors from the fetch requests. If the search or update operations fail, the user won't receive any feedback. Add .catch() handlers to provide error messages to the user.
| class TestInstructorAccessControl(TestCase): | ||
| def setUp(self): | ||
| self.user = get_user_model().objects.create_customuser( | ||
| first_name="Alice", | ||
| last_name="Member", | ||
| email="alice@example.com", | ||
| birthday=timezone.now(), | ||
| municipality="City", | ||
| nick="alice", | ||
| phone="+358111111", | ||
| ) | ||
|
|
||
| def test_machine_access_control_requires_login(self): | ||
| url = reverse("machine-access-control") | ||
| response = self.client.get(url) | ||
| self.assertRedirects(response, f"/www/login/?next={url}") | ||
|
|
||
| def test_machine_access_control_denied_for_basic_user(self): | ||
| self.client.force_login(self.user) | ||
| url = reverse("machine-access-control") | ||
| response = self.client.get(url) | ||
| self.assertRedirects(response, f"/www/login/?next={url}") | ||
|
|
||
| def test_machine_access_control_allowed_for_instructor(self): | ||
| self.user.is_instructor = True | ||
| self.user.save() | ||
| self.client.force_login(self.user) | ||
| url = reverse("machine-access-control") | ||
| response = self.client.get(url) | ||
| self.assertEqual(response.status_code, 200) | ||
| self.assertContains(response, "Machine Access Control") | ||
|
|
||
| def test_machine_access_control_allowed_for_staff(self): | ||
| self.user.is_staff = True | ||
| self.user.save() | ||
| self.client.force_login(self.user) | ||
| url = reverse("machine-access-control") | ||
| response = self.client.get(url) | ||
| self.assertEqual(response.status_code, 200) | ||
| self.assertContains(response, "Machine Access Control") |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing test coverage for the new search_member and update_permission views. While TestInstructorAccessControl tests the page access, it doesn't test the actual search and permission update functionality which are critical features of this PR.
www/views.py
Outdated
|
|
||
| # Contains a list of ID numbers representing all access permissions the user has. | ||
| # The front-end can then use this list to mark the corresponding checkboxes as checked. | ||
| allowed = list(user.access_permissions.values_list("id", flat=True)) |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Variable allowed is not used.
| allowed = list(user.access_permissions.values_list("id", flat=True)) |
| ServiceSubscriptionCountFilter, | ||
| ) | ||
| from .forms import CustomUserChangeForm, CustomUserCreationForm | ||
| from api.models import AccessPermission |
Copilot
AI
Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Import of 'AccessPermission' is not used.
| from api.models import AccessPermission |
…tch error handling
Closes #592