Skip to content
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

Refactor to builtin generic views where appropriate #1964

Merged
merged 39 commits into from
Oct 9, 2023
Merged
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
ba37efd
work
niklasmohrin May 29, 2023
09f3f49
more work
niklasmohrin Jun 5, 2023
7ca0ad7
this one is controversial
niklasmohrin Jun 19, 2023
c6f9497
some nice generic views
niklasmohrin Jun 26, 2023
e7b6fa5
more views
niklasmohrin Jun 26, 2023
614d96d
yet another view
niklasmohrin Jun 26, 2023
d4e5536
make something atomic
niklasmohrin Jul 3, 2023
729316b
classic formview
niklasmohrin Jul 3, 2023
fe89322
staff degree and course type index
niklasmohrin Jul 3, 2023
8af1a6b
user create view
niklasmohrin Jul 3, 2023
1ebde2f
faq index
niklasmohrin Jul 3, 2023
c0a2b20
infotexts
niklasmohrin Jul 3, 2023
c202186
format
niklasmohrin Jul 10, 2023
117c289
create mixins for common stuff
niklasmohrin Jul 10, 2023
7d5e744
user merge selection
niklasmohrin Jul 10, 2023
62411e5
template edit
niklasmohrin Jul 10, 2023
8849da7
Remove unused `editor_required`
niklasmohrin Jul 24, 2023
08b7828
wrong way around
niklasmohrin Jul 24, 2023
9c80711
use `self.object` in `SemesterExportView`
niklasmohrin Jul 24, 2023
8e89e0c
Add renaming functionality to `FormsetView`
niklasmohrin Jul 31, 2023
69028e6
Check that forms redirect
niklasmohrin Jul 31, 2023
e7abaf2
Revert `UploadGradesView`
niklasmohrin Jul 31, 2023
6c73bac
Missed a rename
niklasmohrin Jul 31, 2023
bf73430
unused imports
niklasmohrin Jul 31, 2023
560c598
Don't save form if operation is invalid
niklasmohrin Jul 31, 2023
afe0796
empty commit
niklasmohrin Aug 21, 2023
823e1df
Add tests for `class_or_function_check_decorator`
niklasmohrin Aug 28, 2023
2ade9d5
Make it more linter friendly
niklasmohrin Aug 28, 2023
ebb8d34
Make linter more code friendly
niklasmohrin Aug 28, 2023
2af3830
yes
niklasmohrin Aug 28, 2023
8fae961
remove disable_if_archived stuff because we never render the disable …
niklasmohrin Oct 2, 2023
52f9b64
add comment to class_or_function_check_decorator
niklasmohrin Oct 2, 2023
6dfe419
revert port of `semester_export`
niklasmohrin Oct 2, 2023
f326956
document `FormsetView`
niklasmohrin Oct 2, 2023
140290a
document `SaveValidFormMixin`
niklasmohrin Oct 2, 2023
61cc4a7
fix semester export view reference in urls
niklasmohrin Oct 2, 2023
0e5cd94
remove comments on auth decorators
niklasmohrin Oct 2, 2023
230e5be
remove unused import
niklasmohrin Oct 2, 2023
489c0c7
test some uncovered lines
niklasmohrin Oct 9, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 54 additions & 124 deletions evap/evaluation/auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import inspect
from functools import wraps
from typing import Callable

from django.contrib.auth.backends import ModelBackend
from django.core.exceptions import PermissionDenied
from django.utils.decorators import method_decorator
from mozilla_django_oidc.auth import OIDCAuthenticationBackend

from evap.evaluation.models import UserProfile
Expand Down Expand Up @@ -45,160 +48,87 @@ def authenticate(self, request, email=None, password=None): # pylint: disable=a
return None


def user_passes_test(test_func):
def class_or_function_check_decorator(test_func: Callable[[UserProfile], bool]):
niklasmohrin marked this conversation as resolved.
Show resolved Hide resolved
"""
Decorator for views that checks whether a user passes a given test
(raising 403 if not). The test should be a callable that takes the
user object and returns True if the user passes.
"""

def decorator(view_func):
@wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
if not test_func(request.user):
raise PermissionDenied()
return view_func(request, *args, **kwargs)

return _wrapped_view

return decorator


def internal_required(view_func):
"""
Decorator for views that checks that the user is logged in and not an external user
"""

def check_user(user):
return not user.is_external

return user_passes_test(check_user)(view_func)

Transforms a test function into a decorator that can be used on function-based and class-based views.

def staff_permission_required(view_func):
"""
Decorator for views that checks that the user is logged in and staff (regardless of staff mode!)
"""

def check_user(user):
return user.has_staff_permission

return user_passes_test(check_user)(view_func)


def manager_required(view_func):
"""
Decorator for views that checks that the user is logged in and a manager
"""

def check_user(user):
return user.is_manager

return user_passes_test(check_user)(view_func)


def reviewer_required(view_func):
"""
Decorator for views that checks that the user is logged in and a reviewer
Using the returned decorator on a view enhances the view to return a "Permission Denied" response if the requesting
user does not pass the test function.
"""

def check_user(user):
return user.is_reviewer

return user_passes_test(check_user)(view_func)


def grade_publisher_required(view_func):
"""
Decorator for views that checks that the user is logged in and a grade publisher
"""

def check_user(user):
return user.is_grade_publisher

return user_passes_test(check_user)(view_func)


def grade_publisher_or_manager_required(view_func):
"""
Decorator for views that checks that the user is logged in and a grade publisher or a manager
"""
def function_decorator(func):
@wraps(func)
def wrapped(request, *args, **kwargs):
if not test_func(request.user):
raise PermissionDenied
return func(request, *args, **kwargs)

def check_user(user):
return user.is_grade_publisher or user.is_manager
return wrapped

return user_passes_test(check_user)(view_func)
def decorator(class_or_function):
if inspect.isclass(class_or_function):
# See https://docs.djangoproject.com/en/4.2/topics/class-based-views/intro/#decorating-the-class
return method_decorator(function_decorator, name="dispatch")(class_or_function)
niklasmohrin marked this conversation as resolved.
Show resolved Hide resolved

assert inspect.isfunction(class_or_function)
return function_decorator(class_or_function)

def grade_downloader_required(view_func):
"""
Decorator for views that checks that the user is logged in and can download grades
"""
return decorator

def check_user(user):
return user.can_download_grades

return user_passes_test(check_user)(view_func)
@class_or_function_check_decorator
def internal_required(user):
return not user.is_external


def responsible_or_contributor_or_delegate_required(view_func):
"""
Decorator for views that checks that the user is logged in, is responsible for a course, or is a contributor, or is
a delegate.
"""
@class_or_function_check_decorator
def staff_permission_required(user):
return user.has_staff_permission

def check_user(user):
return user.is_responsible_or_contributor_or_delegate

return user_passes_test(check_user)(view_func)
@class_or_function_check_decorator
def manager_required(user):
return user.is_manager


def editor_or_delegate_required(view_func):
"""
Decorator for views that checks that the user is logged in, has edit rights
for at least one evaluation or is a delegate for such a person.
"""
@class_or_function_check_decorator
def reviewer_required(user):
return user.is_reviewer

def check_user(user):
return user.is_editor_or_delegate

return user_passes_test(check_user)(view_func)
@class_or_function_check_decorator
def grade_publisher_required(user):
return user.is_grade_publisher


def editor_required(view_func):
"""
Decorator for views that checks that the user is logged in and has edit
right for at least one evaluation.
"""
@class_or_function_check_decorator
def grade_publisher_or_manager_required(user):
return user.is_grade_publisher or user.is_manager

def check_user(user):
return user.is_editor

return user_passes_test(check_user)(view_func)
@class_or_function_check_decorator
def grade_downloader_required(user):
return user.can_download_grades


def participant_required(view_func):
"""
Decorator for views that checks that the user is logged in and
participates in at least one evaluation.
"""
@class_or_function_check_decorator
def responsible_or_contributor_or_delegate_required(user):
return user.is_responsible_or_contributor_or_delegate

def check_user(user):
return user.is_participant

return user_passes_test(check_user)(view_func)
@class_or_function_check_decorator
def editor_or_delegate_required(user):
return user.is_editor_or_delegate


def reward_user_required(view_func):
"""
Decorator for views that checks that the user is logged in and can use
reward points.
"""
@class_or_function_check_decorator
def participant_required(user):
return user.is_participant

def check_user(user):
return can_reward_points_be_used_by(user)

return user_passes_test(check_user)(view_func)
@class_or_function_check_decorator
def reward_user_required(user):
return can_reward_points_be_used_by(user)


# see https://mozilla-django-oidc.readthedocs.io/en/stable/
Expand Down
52 changes: 52 additions & 0 deletions evap/evaluation/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
from django.conf import settings
from django.contrib.auth.models import Group
from django.core import mail
from django.core.exceptions import PermissionDenied
from django.http import HttpRequest, HttpResponse
from django.test import override_settings
from django.urls import reverse
from django.views import View
from model_bakery import baker

from evap.evaluation import auth
from evap.evaluation.auth import class_or_function_check_decorator
from evap.evaluation.models import Contribution, Evaluation, UserProfile
from evap.evaluation.tests.tools import WebTest

Expand Down Expand Up @@ -176,3 +180,51 @@ def test_entering_staff_mode_after_logout_and_login(self):
page = page.forms["enter-staff-mode-form"].submit().follow().follow()
self.assertTrue("staff_mode_start_time" in self.app.session)
self.assertContains(page, "Users")


class TestAuthDecorators(WebTest):
@classmethod
def setUpTestData(cls):
@class_or_function_check_decorator
def check_decorator(user: UserProfile) -> bool:
return getattr(user, "some_condition") # mocked later

@check_decorator
def function_based_view(_request):
return HttpResponse()

@check_decorator
class ClassBasedView(View):
def get(self, _request):
return HttpResponse()

cls.user = baker.make(UserProfile, email="testuser@institution.example.com")
cls.function_based_view = function_based_view
cls.class_based_view = ClassBasedView.as_view()

@classmethod
def make_request(cls):
request = HttpRequest()
request.method = "GET"
request.user = cls.user
return request

@patch("evap.evaluation.models.UserProfile.some_condition", True, create=True)
def test_passing_user_function_based(self):
response = self.function_based_view(self.make_request()) # pylint: disable=too-many-function-args
self.assertEqual(response.status_code, 200)

@patch("evap.evaluation.models.UserProfile.some_condition", True, create=True)
def test_passing_user_class_based(self):
response = self.class_based_view(self.make_request())
self.assertEqual(response.status_code, 200)

@patch("evap.evaluation.models.UserProfile.some_condition", False, create=True)
def test_failing_user_function_based(self):
with self.assertRaises(PermissionDenied):
self.function_based_view(self.make_request()) # pylint: disable=too-many-function-args

@patch("evap.evaluation.models.UserProfile.some_condition", False, create=True)
def test_failing_user_class_based(self):
with self.assertRaises(PermissionDenied):
self.class_based_view(self.make_request())
45 changes: 45 additions & 0 deletions evap/evaluation/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.utils.translation import get_language
from django.views.generic import FormView

M = TypeVar("M", bound=Model)
T = TypeVar("T")
Expand Down Expand Up @@ -138,6 +139,50 @@ def assert_not_none(value: T | None) -> T:
return value


class FormsetView(FormView):
"""
Just like `FormView`, but with a renaming from "form" to "formset".
"""

@property
def form_class(self):
return self.formset_class

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["formset"] = context.pop("form")
return context

# As an example for the logic, consider the following: Django calls `get_form_kwargs`, which we delegate to
# `get_formset_kwargs`. Users can thus override `get_formset_kwargs` instead. If it is not overridden, we delegate
# to the original `get_form_kwargs` instead. The same approach is used for the other renamed methods.

def get_form_kwargs(self):
return self.get_formset_kwargs()

def get_formset_kwargs(self):
return super().get_form_kwargs()

def form_valid(self, form):
return self.formset_valid(form)

def formset_valid(self, formset):
return super().form_valid(formset)
niklasmohrin marked this conversation as resolved.
Show resolved Hide resolved


class SaveValidFormMixin:
"""
Call `form.save()` if the submitted form is valid.

Django's `ModelFormMixin` (which inherits from `SingleObjectMixin`) does the same, but cannot always be used, for
example if a formset for a collection of objects is submitted.
"""

def form_valid(self, form):
form.save()
return super().form_valid(form)


class AttachmentResponse(HttpResponse):
"""
Helper class that sets the correct Content-Disposition header for a given
Expand Down
6 changes: 3 additions & 3 deletions evap/grades/templates/grades_course_view.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ <h3 class="mb-3">{{ course.name }} ({{ semester.name }})</h3>
<a href="{% url 'grades:download_grades' grade_document.id %}" class="btn btn-sm btn-light" data-bs-toggle="tooltip" data-bs-placement="top" title="{% trans 'Download' %}"><span class="fas fa-download"></span></a>
{% if user.is_grade_publisher %}
<a href="{% url 'grades:edit_grades' grade_document.id %}" class="btn btn-sm btn-light" data-bs-toggle="tooltip" data-bs-placement="top" title="{% trans 'Edit' %}"><span class="fas fa-pencil"></span></a>
<button type="button" {{ disable_if_archived }} onclick="deleteGradedocumentModalShow({{ grade_document.id }}, '{{ grade_document.description|escapejs }}');" class="btn btn-sm btn-danger" data-bs-toggle="tooltip" data-bs-placement="top" title="{% trans 'Delete' %}">
<button type="button" onclick="deleteGradedocumentModalShow({{ grade_document.id }}, '{{ grade_document.description|escapejs }}');" class="btn btn-sm btn-danger" data-bs-toggle="tooltip" data-bs-placement="top" title="{% trans 'Delete' %}">
<span class="fas fa-trash"></span>
</button>
{% endif %}
Expand All @@ -45,8 +45,8 @@ <h3 class="mb-3">{{ course.name }} ({{ semester.name }})</h3>
</div>
</div>
{% if user.is_grade_publisher %}
<a href="{% url 'grades:upload_grades' course.id %}" class="btn btn-dark {{ disable_if_archived }}">{% trans 'Upload new midterm grades' %}</a>
<a href="{% url 'grades:upload_grades' course.id %}?final=true" class="btn btn-dark {{ disable_if_archived }}">{% trans 'Upload new final grades' %}</a>
<a href="{% url 'grades:upload_grades' course.id %}" class="btn btn-dark">{% trans 'Upload new midterm grades' %}</a>
<a href="{% url 'grades:upload_grades' course.id %}?final=true" class="btn btn-dark">{% trans 'Upload new final grades' %}</a>
{% endif %}
{% endblock %}

Expand Down
4 changes: 2 additions & 2 deletions evap/grades/templates/grades_semester_view.html
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ <h3 class="col-8 mb-0">
<td class="text-end" style="min-width:72px">
{% if not course.gets_no_grade_documents %}
{% if num_final_grades == 0 %}
<button type="button" {{ disable_if_archived }} onclick="confirmNouploadModalShow({{ course.id }}, '{{ course.name|escapejs }}');" class="btn btn-sm btn-secondary" data-bs-toggle="tooltip" data-bs-placement="top" title="{% trans 'Confirm that final grades have been submitted but will not be uploaded.' %}">
<button type="button" onclick="confirmNouploadModalShow({{ course.id }}, '{{ course.name|escapejs }}');" class="btn btn-sm btn-secondary" data-bs-toggle="tooltip" data-bs-placement="top" title="{% trans 'Confirm that final grades have been submitted but will not be uploaded.' %}">
<span class="fas fa-xmark"></span>
</button>
<div class="btn-group" role="group" data-bs-toggle="tooltip" data-bs-placement="top" title="{% trans 'Upload grade document' %}">
Expand All @@ -85,7 +85,7 @@ <h3 class="col-8 mb-0">
</div>
</div>
{% else %}
<a href="{% url 'grades:course_view' course.id %}" class="btn btn-sm btn-light {{ disable_if_archived }}" data-bs-toggle="tooltip" data-bs-placement="top" title="{% trans 'Change grade document' %}"><span class="fas fa-pencil"></span></a>
<a href="{% url 'grades:course_view' course.id %}" class="btn btn-sm btn-light" data-bs-toggle="tooltip" data-bs-placement="top" title="{% trans 'Change grade document' %}"><span class="fas fa-pencil"></span></a>
{% endif %}
{% endif %}
</td>
Expand Down
Loading