diff --git a/accounts/admin.py b/accounts/admin.py index fba187332cebf316137171707917e2073089c3ca..4892cd3c86a4e3a4125a079c5807d55b3de4accf 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -27,29 +27,43 @@ THE SOFTWARE. from django.contrib import admin from django.contrib.auth.admin import UserAdmin as UserAdminBase -from django.utils.translation import ugettext_lazy as _ # noqa +from django.utils.translation import ugettext_lazy as _ +from django.db.models import Q + from . models import User +from course.models import Course, Participation +from course.admin import ( + _filter_courses_for_user, _filter_course_linked_obj_for_user) -def _remove_from_fieldsets(fs, field_name): - return tuple( - (heading, {"fields": - tuple( - f for f in props["fields"] - if f != field_name)}) - for heading, props in fs) +def _get_filter_participations_for_user(user): + participations = Participation.objects.all() + if not user.is_superuser: + participations = _filter_course_linked_obj_for_user(participations, user) + return participations -class UserAdmin(UserAdminBase): - # list_display = tuple( - # f for f in UserAdminBase.list_display - # if f != "is_staff") - # list_filter = tuple( - # f for f in UserAdminBase.list_filter - # if f != "is_staff") - # fieldsets = _remove_from_fieldsets( - # UserAdminBase.fieldsets, "is_staff") +class CourseListFilter(admin.SimpleListFilter): + title = _("Course") + parameter_name = "course__identifier" + + def lookups(self, request, model_admin): + course_identifiers = ( + _filter_courses_for_user(Course.objects, request.user) + .values_list("identifier", flat=True)) + return zip(course_identifiers, course_identifiers) + def queryset(self, request, queryset): + if self.value(): + participations = ( + _get_filter_participations_for_user(request.user) + .filter(course__identifier=self.value())) + return queryset.filter(pk__in=participations.values_list("user__pk")) + else: + return queryset + + +class UserAdmin(UserAdminBase): save_on_top = True list_display = tuple(UserAdminBase.list_display) + ( @@ -63,7 +77,7 @@ class UserAdmin(UserAdminBase): "institutional_id", "institutional_id_verified", "name_verified",) list_filter = tuple(UserAdminBase.list_filter) + ( - "status", "participations__course") + "status", CourseListFilter) # type: ignore search_fields = tuple(UserAdminBase.search_fields) + ( "institutional_id",) @@ -79,6 +93,56 @@ class UserAdmin(UserAdminBase): "editor_mode",) }), ) + UserAdminBase.fieldsets[2:] + ordering = ["-date_joined"] + + def get_fieldsets(self, request, obj=None): + fieldsets = super(UserAdmin, self).get_fieldsets(request, obj) + if request is not None and request.user.is_superuser: + return fieldsets + return tuple( + [fields for fields in fieldsets + if "is_superuser" not in fields[1]["fields"] + and "is_staff" not in fields[1]["fields"] + and "user_permissions" not in fields[1]["fields"]]) + + def get_list_display(self, request): + list_display = super(UserAdmin, self).get_list_display(request) + if request is not None and request.user.is_superuser: + return list_display + return tuple([f for f in list_display if f != "is_staff"]) + + def get_list_filter(self, request): + list_filter = super(UserAdmin, self).get_list_filter(request) + if request is not None and request.user.is_superuser: + return list_filter + return tuple([f for f in list_filter if f != "is_staff"]) + + def get_queryset(self, request): + qs = super(UserAdmin, self).get_queryset(request) + + if request is not None and request.user.is_superuser: + return qs + + user_courses = _filter_courses_for_user(Course.objects, request.user) + + # Prevent users which attended other courses from being + # deleted or edited. + users_from_other_course = ( + Participation.objects.exclude(course__in=user_courses) + .values_list("user", flat=True)) + + return ( + qs.filter(is_superuser=False) + .filter( + # add the request.user back + Q(pk=request.user.pk) + | ~Q( + # remove users who is_staff from the queryset + Q(is_staff=True) + | + # remove users who attended other courses + Q(pk__in=users_from_other_course)) + )) admin.site.register(User, UserAdmin) diff --git a/accounts/models.py b/accounts/models.py index c2bd737bbe611ae343623c53984f85b94a327830..69eed4c3dea891fcbf9f38b82dada980c11a6c1b 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -222,14 +222,20 @@ class User(AbstractBaseUser, PermissionsMixin): def clean(self): super(User, self).clean() - qset = self.__class__.objects.filter(email__iexact=self.email) - if self.pk is not None: - # In case editing an existing user object - qset = qset.exclude(pk=self.pk) - if qset.exists(): - from django.core.exceptions import ValidationError - raise ValidationError( - {"email": _("That email address is already in use.")}) + + # email can be None in Django admin when create new user + if self.email is not None: + self.email = self.email.strip() + + if self.email: + qset = self.__class__.objects.filter(email__iexact=self.email) + if self.pk is not None: + # In case editing an existing user object + qset = qset.exclude(pk=self.pk) + if qset.exists(): + from django.core.exceptions import ValidationError + raise ValidationError( + {"email": _("That email address is already in use.")}) def save(self, *args, **kwargs): update_fields = kwargs.get("update_fields") diff --git a/course/admin.py b/course/admin.py index cfac4b395f2db3ed4a4f75b8d393ce769f8152b8..4451cd82698dd2fbf3762728cb71bcf2d0d348c9 100644 --- a/course/admin.py +++ b/course/admin.py @@ -50,7 +50,7 @@ from course.constants import ( ) if False: - from typing import Any # noqa + from typing import Any, Text, Tuple # noqa # {{{ permission helpers @@ -85,6 +85,15 @@ def _filter_participation_linked_obj_for_user(queryset, user): # }}} +# {{{ list filter helper + +def _filter_related_only(filter_arg): + # type: (Text) -> Tuple[Text, Any] + return (filter_arg, admin.RelatedOnlyFieldListFilter) + +# }}} + + # {{{ course class UnsafePasswordInput(forms.TextInput): @@ -168,7 +177,7 @@ class EventAdmin(admin.ModelAdmin): "time", "end_time", "shown_in_calendar") - list_filter = ("course", "kind", "shown_in_calendar") + list_filter = (_filter_related_only("course"), "kind", "shown_in_calendar") date_hierarchy = "time" @@ -209,7 +218,7 @@ admin.site.register(Event, EventAdmin) # {{{ participation tags class ParticipationTagAdmin(admin.ModelAdmin): - list_filter = ("course",) + list_filter = (_filter_related_only("course"),) # {{{ permissions @@ -242,7 +251,7 @@ class ParticipationRolePermissionInline(admin.TabularInline): class ParticipationRoleAdmin(admin.ModelAdmin): inlines = (ParticipationRolePermissionInline,) - list_filter = ("course", "identifier") + list_filter = (_filter_related_only("course"), "identifier") admin.site.register(ParticipationRole, ParticipationRoleAdmin) @@ -312,7 +321,7 @@ class ParticipationAdmin(admin.ModelAdmin): "get_roles", "status", ) - list_filter = ("course", "roles__name", "status", "tags") + list_filter = (_filter_related_only("course"), "roles__name", "status", "tags") raw_id_fields = ("user",) @@ -361,7 +370,7 @@ class ParticipationPreapprovalAdmin(admin.ModelAdmin): list_display = ("email", "institutional_id", "course", "get_roles", "creation_time", "creator") - list_filter = ("course", "roles") + list_filter = (_filter_related_only("course"), "roles") search_fields = ( "email", "institutional_id", @@ -412,7 +421,7 @@ admin.site.register(AuthenticationToken, AuthenticationTokenAdmin) class InstantFlowRequestAdmin(admin.ModelAdmin): list_display = ("course", "flow_id", "start_time", "end_time", "cancelled") - list_filter = ("course",) + list_filter = (_filter_related_only("course"),) date_hierarchy = "start_time" @@ -472,7 +481,7 @@ class FlowSessionAdmin(admin.ModelAdmin): date_hierarchy = "start_time" list_filter = ( - "course", + _filter_related_only("course"), "flow_id", "in_progress", "access_rules_tag", @@ -534,6 +543,32 @@ class HasAnswerListFilter(admin.SimpleListFilter): return queryset.filter(answer__isnull=self.value() != "y") +class FlowIdListFilter(admin.SimpleListFilter): + """ + This is only necessary when flow_id is only accessible by FlowSession, which is + a ForeignKey in the model + """ + title = _("Flow ID") + parameter_name = "flow_id" + + def lookups(self, request, model_admin): + qs = model_admin.get_queryset(request) + if not request.user.is_superuser: + qs = qs.filter( + flow_session__course__participations__user=request.user, + flow_session__course__participations__roles__permissions__permission # noqa + =pperm.use_admin_interface) + + flow_ids = qs.values_list("flow_session__flow_id", flat=True).distinct() + return zip(flow_ids, flow_ids) + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(flow_session__flow_id=self.value()) + else: + return queryset + + class FlowPageVisitAdmin(admin.ModelAdmin): def get_course(self, obj): return obj.flow_session.course @@ -582,8 +617,8 @@ class FlowPageVisitAdmin(admin.ModelAdmin): HasAnswerListFilter, "is_submitted_answer", "is_synthetic", - "flow_session__participation__course", - "flow_session__flow_id", + _filter_related_only("flow_session__participation__course"), + FlowIdListFilter, ) date_hierarchy = "visit_time" list_display = ( @@ -678,7 +713,7 @@ class FlowRuleExceptionAdmin(admin.ModelAdmin): "flow_id", ) list_filter = ( - "participation__course", + _filter_related_only("participation__course"), "flow_id", "kind", ) @@ -723,7 +758,7 @@ class GradingOpportunityAdmin(admin.ModelAdmin): "shown_in_participant_grade_book", ) list_filter = ( - "course", + _filter_related_only("course"), "shown_in_grade_book", "shown_in_participant_grade_book", ) @@ -805,8 +840,8 @@ class GradeChangeAdmin(admin.ModelAdmin): ) list_filter = ( - "opportunity__course", - "opportunity", + _filter_related_only("opportunity__course"), + _filter_related_only("opportunity"), "state", ) @@ -845,7 +880,7 @@ class InstantMessageAdmin(admin.ModelAdmin): get_participant.short_description = _("Participant") # type: ignore get_participant.admin_order_field = "participation__user" # type: ignore - list_filter = ("participation__course",) + list_filter = (_filter_related_only("participation__course"),) list_display = ( "get_course", "get_participant", @@ -886,7 +921,7 @@ admin.site.register(InstantMessage, InstantMessageAdmin) class ExamAdmin(admin.ModelAdmin): list_filter = ( - "course", + _filter_related_only("course"), "active", "listed", ) @@ -932,7 +967,7 @@ class ExamTicketAdmin(admin.ModelAdmin): get_course.admin_order_field = "participation__course" # type: ignore list_filter = ( - "participation__course", + _filter_related_only("participation__course"), "state", ) diff --git a/tests/base_test_mixins.py b/tests/base_test_mixins.py index f8c218de8e1b16fb3831e8f7f13f67946a3209df..0a41194041583706501e36d45d4219cabb7635c0 100644 --- a/tests/base_test_mixins.py +++ b/tests/base_test_mixins.py @@ -407,11 +407,11 @@ class SuperuserCreateMixin(ResponseContextMixin): pretended = session.get("relate_pretend_facilities", None) self.assertIsNone(pretended) - def assertFormErrorLoose(self, response, error): # noqa + def assertFormErrorLoose(self, response, error, form_name="form"): # noqa """Assert that error is found in response.context['form'] errors""" import itertools form_errors = list( - itertools.chain(*response.context['form'].errors.values())) + itertools.chain(*response.context[form_name].errors.values())) self.assertIn(str(error), form_errors) @@ -1405,4 +1405,98 @@ def improperly_configured_cache_patch(): return mock.patch(built_in_import_path, side_effect=my_disable_cache_import) + +# {{{ admin + +ADMIN_TWO_COURSE_SETUP_LIST = deepcopy(TWO_COURSE_SETUP_LIST) +# switch roles +ADMIN_TWO_COURSE_SETUP_LIST[1]["participations"][0]["role_identifier"] = "ta" +ADMIN_TWO_COURSE_SETUP_LIST[1]["participations"][1]["role_identifier"] = "instructor" # noqa + + +class AdminTestMixin(TwoCourseTestMixin): + courses_setup_list = ADMIN_TWO_COURSE_SETUP_LIST + none_participation_user_create_kwarg_list = ( + NONE_PARTICIPATION_USER_CREATE_KWARG_LIST) + + @classmethod + def setUpTestData(cls): # noqa + super(AdminTestMixin, cls).setUpTestData() # noqa + + # create 2 participation (with new user) for course1 + from tests.factories import ParticipationFactory + + cls.course1_student_participation2 = ( + ParticipationFactory.create(course=cls.course1)) + cls.course1_student_participation3 = ( + ParticipationFactory.create(course=cls.course1)) + cls.instructor1 = cls.course1_instructor_participation.user + cls.instructor2 = cls.course2_instructor_participation.user + assert cls.instructor1 != cls.instructor2 + + # grant all admin permissions to instructors + from django.contrib.auth.models import Permission + + for user in [cls.instructor1, cls.instructor2]: + user.is_staff = True + user.save() + for perm in Permission.objects.all(): + user.user_permissions.add(perm) + + @classmethod + def get_admin_change_list_view_url(cls, app_name, model_name): + return reverse("admin:%s_%s_changelist" % (app_name, model_name)) + + @classmethod + def get_admin_change_view_url(cls, app_name, model_name, args=None): + if args is None: + args = [] + return reverse("admin:%s_%s_change" % (app_name, model_name), args=args) + + def get_admin_form_fields(self, response): + """ + Return a list of AdminFields for the AdminForm in the response. + """ + admin_form = response.context['adminform'] + fieldsets = list(admin_form) + + field_lines = [] + for fieldset in fieldsets: + field_lines += list(fieldset) + + fields = [] + for field_line in field_lines: + fields += list(field_line) + + return fields + + def get_admin_form_fields_names(self, response): + return [f.field.name for f in self.get_admin_form_fields(response)] + + def get_changelist(self, request, model, modeladmin): + from django.contrib.admin.views.main import ChangeList + return ChangeList( + request, model, modeladmin.list_display, + modeladmin.list_display_links, modeladmin.list_filter, + modeladmin.date_hierarchy, modeladmin.search_fields, + modeladmin.list_select_related, modeladmin.list_per_page, + modeladmin.list_max_show_all, modeladmin.list_editable, modeladmin, + ) + + def get_filterspec_list(self, request, changelist=None, model=None, + modeladmin=None): + if changelist is None: + assert request and model and modeladmin + changelist = self.get_changelist(request, model, modeladmin) + + filterspecs = changelist.get_filters(request)[0] + filterspec_list = [] + for filterspec in filterspecs: + choices = tuple(c['display'] for c in filterspec.choices(changelist)) + filterspec_list.append(choices) + + return filterspec_list + +# }}} + # vim: fdm=marker diff --git a/tests/factories.py b/tests/factories.py index 142b9ddba7abce4262716fb960f10d0d1eeca7ed..64e4ef9ce1360659b955cc7e0749e80e1fe3184c 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -138,3 +138,13 @@ class FlowPageVisitFactory(factory.django.DjangoModelFactory): user = factory.lazy_attribute( lambda x: x.page_data.flow_session.participation.user) answer = None + + +class EventFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.Event + + course = factory.SubFactory(CourseFactory) + kind = "default_kind" + ordinal = factory.Sequence(lambda n: n) + time = factory.LazyFunction(now) diff --git a/tests/test_accounts/test_admin.py b/tests/test_accounts/test_admin.py new file mode 100644 index 0000000000000000000000000000000000000000..204c14fb47951b36441375268544f0e805f2de6a --- /dev/null +++ b/tests/test_accounts/test_admin.py @@ -0,0 +1,424 @@ +from __future__ import division + +__copyright__ = "Copyright (C) 2018 Dong Zhuang" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +import six +from unittest import skipIf + +from django.test import TestCase, RequestFactory +from django.urls import reverse +from django.utils.timezone import now +from django.contrib.auth import get_user_model +from django.contrib.admin import site +from django.contrib.admin.models import LogEntry + +from course.models import Participation +from accounts.admin import UserAdmin +from accounts.models import User + +from tests.base_test_mixins import AdminTestMixin + + +class AccountsAdminTest(AdminTestMixin, TestCase): + + @classmethod + def setUpTestData(cls): # noqa + super(AccountsAdminTest, cls).setUpTestData() + + cls.user_change_list_url = cls.get_admin_change_list_view_url() + cls.superuser_change_url = ( + cls.get_admin_change_view_url(args=(cls.superuser.pk,))) + cls.instructor1_change_url = ( + cls.get_admin_change_view_url(args=(cls.instructor1.pk,))) + cls.student1_change_url = ( + cls.get_admin_change_view_url( + args=(cls.course1_student_participation.user.pk,))) + cls.student2_change_url = ( + cls.get_admin_change_view_url( + args=(cls.course1_student_participation2.user.pk,))) + + @classmethod + def get_admin_change_list_view_url(cls): + return super(AccountsAdminTest, cls).get_admin_change_list_view_url( + app_name="accounts", model_name="user") + + @classmethod + def get_admin_change_view_url(cls, args=None): + return super(AccountsAdminTest, cls).get_admin_change_view_url( + app_name="accounts", model_name="user", args=args) + + def setUp(self): + super(AccountsAdminTest, self).setUp() + self.superuser.refresh_from_db() + self.rf = RequestFactory() + + @skipIf(six.PY2, "PY2 doesn't support subTest") + def test_change_view(self): + + with self.subTest("superuser admin change/changelist for " + "accounts.user"): + with self.temporarily_switch_to_user(self.superuser): + # list view + resp = self.c.get(self.user_change_list_url) + self.assertEqual(resp.status_code, 200) + + # change view + resp = self.c.get(self.superuser_change_url) + self.assertEqual(resp.status_code, 200) + + resp = self.c.get(self.instructor1_change_url) + self.assertEqual(resp.status_code, 200) + + with self.subTest("staff 1 admin change/changelist for " + "accounts.user"): + with self.temporarily_switch_to_user(self.instructor1): + resp = self.c.get(self.user_change_list_url) + self.assertEqual(resp.status_code, 200) + + resp = self.c.get(self.superuser_change_url) + self.assertEqual(resp.status_code, 302) + + resp = self.c.get(self.instructor1_change_url) + self.assertEqual(resp.status_code, 200) + + resp = self.c.get(self.student2_change_url) + self.assertEqual(resp.status_code, 200) + + # because that student joined 2 courses + resp = self.c.get(self.student1_change_url) + self.assertEqual(resp.status_code, 302) + + with self.subTest("staff 2 admin change/changelist for " + "accounts.user"): + with self.temporarily_switch_to_user(self.instructor2): + resp = self.c.get(self.user_change_list_url) + self.assertEqual(resp.status_code, 200) + + resp = self.c.get(self.superuser_change_url) + self.assertEqual(resp.status_code, 302) + + # Because instructor 1 is also a staff + resp = self.c.get(self.instructor1_change_url) + self.assertEqual(resp.status_code, 302) + + # because that student joined 2 courses + resp = self.c.get(self.student1_change_url) + self.assertEqual(resp.status_code, 302) + + # because that student didn't join this course + resp = self.c.get(self.student2_change_url) + self.assertEqual(resp.status_code, 302) + + def test_admin_add_user(self): + # This is to make sure admin can add user without email. + # Make sure https://github.com/inducer/relate/issues/447 is fixed + with self.temporarily_switch_to_user(self.instructor1): + user_count = get_user_model().objects.count() + resp = self.c.post(reverse('admin:accounts_user_add'), { + 'username': 'newuser', + 'password1': 'newpassword', + 'password2': 'newpassword', + }) + new_user = get_user_model().objects.get(username='newuser') + self.assertRedirects(resp, reverse('admin:accounts_user_change', + args=(new_user.pk,))) + self.assertEqual(get_user_model().objects.count(), user_count + 1) + self.assertTrue(new_user.has_usable_password()) + + @skipIf(six.PY2, "PY2 doesn't support subTest") + def test_admin_user_change_fieldsets(self): + # This test is using request factory + change_url = reverse('admin:accounts_user_change', + args=(self.course1_student_participation3.user.pk,)) + + common_fields = ( + "username", + "password", + "status", + "first_name", + "last_name", + "name_verified", + "email", + "institutional_id", + "institutional_id_verified", + "editor_mode", + "last_login", + "date_joined", + ) + + superuser_only_fields = ( + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + ) + + test_dicts = [{"user": self.superuser, + "see_common_fields": True, + "see_superuser_only_fields": True}, + {"user": self.instructor1, + "see_common_fields": True, + "see_superuser_only_fields": False}] + + for td in test_dicts: + with self.subTest(user=td["user"]): + with self.temporarily_switch_to_user(td["user"]): + resp = self.c.get(change_url) + self.assertEqual(resp.status_code, 200) + field_names = self.get_admin_form_fields_names(resp) + + for f in common_fields: + self.assertEqual( + td["see_common_fields"], f in field_names, + "'%s' unexpectedly %s SHOWN in %s" + % (f, + "NOT" if td["see_superuser_only_fields"] + else "", + repr(field_names)) + ) + for f in superuser_only_fields: + self.assertEqual( + td["see_superuser_only_fields"], f in field_names, + "'%s' unexpectedly %s SHOWN in %s" + % (f, + "NOT" if td["see_superuser_only_fields"] + else "", + repr(field_names))) + + def test_list_display_is_staff_field(self): + modeladmin = UserAdmin(User, site) + request = self.rf.get(self.user_change_list_url, {}) + request.user = self.superuser + list_display = modeladmin.get_list_display(request) + self.assertIn("is_staff", list_display) + + # ensure "is_staff" not present in list_display of staff view + request.user = self.instructor1 + list_display = modeladmin.get_list_display(request) + self.assertNotIn("is_staff", list_display) + + def test_list_filter_is_staff_field(self): + modeladmin = UserAdmin(User, site) + request = self.rf.get(self.user_change_list_url, {}) + request.user = self.superuser + list_filter = modeladmin.get_list_filter(request) + self.assertIn("is_staff", list_filter) + + # ensure "is_staff" not present in list_filter of staff view + request.user = self.instructor1 + list_filter = modeladmin.get_list_filter(request) + self.assertNotIn("is_staff", list_filter) + + def test_list_editable_is_staff_field(self): + # ensuer "is_staff" not present in list_editable + # notice that, list_editable must be a subset of list_display, + # or it will raise an check error. + modeladmin = UserAdmin(User, site) + request = self.rf.get(self.user_change_list_url, {}) + request.user = self.superuser + + changelist = self.get_changelist(request, User, modeladmin) + self.assertNotIn("is_staff", changelist.list_editable) + + request.user = self.instructor1 + changelist = self.get_changelist(request, User, modeladmin) + self.assertNotIn("is_staff", changelist.list_editable) + + def test_list_filter_queryset_filter(self): + """ + A list filter that filters the queryset by default gives the correct + full_result_count. + """ + total_user_count = User.objects.count() + modeladmin = UserAdmin(User, site) + + # {{{ not filtered + request = self.rf.get(self.user_change_list_url, {}) + request.user = self.superuser + changelist = self.get_changelist(request, User, modeladmin) + + changelist.get_results(request) + self.assertEqual(changelist.full_result_count, total_user_count) + + filterspec_list = self.get_filterspec_list(request, changelist) + self.assertIn(('All', self.course1.identifier, self.course2.identifier), + filterspec_list) + + request = self.rf.get(self.user_change_list_url, {}) + request.user = self.instructor1 + changelist = self.get_changelist(request, User, modeladmin) + changelist.get_results(request) + filterspec_list = self.get_filterspec_list(request, changelist) + + # 2 users created in setUp 'testuser_001', 'testuser_000', + # 4 non-participation users 'test_user4', 'test_user3', 'test_user2', + # 'test_user1', + # 1 instructor 'test_instructor' (request.user) + self.assertEqual(changelist.full_result_count, 7) + + queryset = changelist.get_queryset(request) + self.assertIn(self.instructor1, queryset) + + # Besides 'test_admin' (superuser), 'test_ta' (who is also a staff) + # and 'test_student' (who attend two courses) were not included + self.assertNotIn(self.superuser, queryset) + self.assertNotIn(self.course1_student_participation.user, queryset) + self.assertNotIn(self.instructor2, queryset) + + # Although instructor 1 attended course2, the list_filter did not have that + # choice, because he/she has no view_admin_interface pperm in that course + self.assertIn(('All', self.course1.identifier), filterspec_list) + + # }}} + + # {{{ filtered by course 1 + + request = self.rf.get(self.user_change_list_url, + {"course__identifier": self.course1.identifier}) + request.user = self.superuser + changelist = self.get_changelist(request, User, modeladmin) + + queryset = changelist.get_queryset(request) + self.assertEqual( + queryset.count(), + Participation.objects.filter( + course__identifier=self.course1.identifier).count()) + + request = self.rf.get(self.user_change_list_url, + {"course__identifier": self.course1.identifier}) + request.user = self.instructor1 + changelist = self.get_changelist(request, User, modeladmin) + queryset = changelist.get_queryset(request) + + # 2 users created in setUp 'testuser_001', 'testuser_000', + # 1 instructor 'test_instructor' + self.assertEqual(queryset.count(), 3) + + # }}} + + def get_user_data(self, user): + if not user.last_login: + user.last_login = now() + if not user.date_joined: + user.date_joined = now() + return { + 'username': user.username, + 'password': user.password, + 'email': user.email, + 'is_active': user.is_active, + 'is_staff': user.is_staff, + 'is_superuser': user.is_superuser, + 'last_login_0': user.last_login.strftime('%Y-%m-%d'), + 'last_login_1': user.last_login.strftime('%H:%M:%S'), + 'initial-last_login_0': user.last_login.strftime('%Y-%m-%d'), + 'initial-last_login_1': user.last_login.strftime('%H:%M:%S'), + 'date_joined_0': user.date_joined.strftime('%Y-%m-%d'), + 'date_joined_1': user.date_joined.strftime('%H:%M:%S'), + 'initial-date_joined_0': user.date_joined.strftime('%Y-%m-%d'), + 'initial-date_joined_1': user.date_joined.strftime('%H:%M:%S'), + 'first_name': user.first_name, + 'last_name': user.last_name, + 'editor_mode': user.editor_mode, + 'status': user.status + } + + def test_set_superuser_and_staff_by_superuser(self): + user = self.course1_student_participation2.user + with self.temporarily_switch_to_user(self.superuser): + staff_count = User.objects.filter(is_staff=True).count() + superuser_count = User.objects.filter(is_superuser=True).count() + data = self.get_user_data(user) + data["is_staff"] = True + resp = self.c.post(self.student2_change_url, data) + self.assertEqual(resp.status_code, 302) + self.assertEqual(User.objects.filter(is_staff=True).count(), + staff_count + 1) + row = LogEntry.objects.latest('id') + self.assertIn("Changed", row.get_change_message()) + self.assertIn("is_staff", row.get_change_message()) + + data = self.get_user_data(user) + data["is_superuser"] = True + self.c.post(self.student2_change_url, data) + self.assertEqual(User.objects.filter(is_superuser=True).count(), + superuser_count + 1) + row = LogEntry.objects.latest('id') + self.assertIn("Changed", row.get_change_message()) + self.assertIn("is_superuser", row.get_change_message()) + + def test_set_superuser_and_staff_by_staff(self): + user = self.course1_student_participation2.user + with self.temporarily_switch_to_user(self.instructor1): + staff_count = User.objects.filter(is_staff=True).count() + superuser_count = User.objects.filter(is_superuser=True).count() + data = self.get_user_data(user) + data["is_staff"] = True + resp = self.c.post(self.student2_change_url, data) + self.assertEqual(resp.status_code, 302) + + # non-superuser staff can't post create staff + self.assertEqual(User.objects.filter(is_staff=True).count(), + staff_count) + row = LogEntry.objects.latest('id') + self.assertNotIn("is_staff", row.get_change_message()) + + data = self.get_user_data(user) + data["is_superuser"] = True + self.c.post(self.student2_change_url, data) + + # non-superuser staff can't post create superuser + self.assertEqual(User.objects.filter(is_superuser=True).count(), + superuser_count) + row = LogEntry.objects.latest('id') + self.assertNotIn("is_superuser", row.get_change_message()) + + def test_add_permissions_by_superuser(self): + user = self.course1_student_participation2.user + with self.temporarily_switch_to_user(self.superuser): + data = self.get_user_data(user) + # add a permission in post data + data["user_permissions"] = [1, ] + + self.c.post(self.student2_change_url, data) + + row = LogEntry.objects.latest('id') + self.assertIn("Changed", row.get_change_message()) + self.assertIn("user_permissions", row.get_change_message()) + + def test_add_permissions_by_staff(self): + user = self.course1_student_participation2.user + with self.temporarily_switch_to_user(self.instructor1): + data = self.get_user_data(user) + # try to add a permission in post data + data["user_permissions"] = [1, ] + + self.c.post(self.student2_change_url, data) + + row = LogEntry.objects.latest('id') + self.assertIn("Changed", row.get_change_message()) + + # no change was made to user_permissions + self.assertNotIn("user_permissions", row.get_change_message()) + +# vim: foldmethod=marker diff --git a/tests/test_admin.py b/tests/test_admin.py new file mode 100644 index 0000000000000000000000000000000000000000..8cab7038a70197769ac992971f9111aa8244a60c --- /dev/null +++ b/tests/test_admin.py @@ -0,0 +1,220 @@ +from __future__ import division + +__copyright__ = "Copyright (C) 2018 Dong Zhuang" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +import six +from unittest import skipIf + +from django.test import TestCase, RequestFactory +from django.contrib.admin import site + +from course import models, admin + +from tests.base_test_mixins import AdminTestMixin +from tests import factories + + +class CourseAdminTest(AdminTestMixin, TestCase): + + @classmethod + def setUpTestData(cls): # noqa + super(CourseAdminTest, cls).setUpTestData() # noqa + + cls.course1_session = factories.FlowSessionFactory.create( + participation=cls.course1_student_participation2, + flow_id="001-linalg-recap") + course1_flow_page_data = factories.FlowPageDataFactory.create( + flow_session=cls.course1_session + ) + cls.coures1_visit = ( + factories.FlowPageVisitFactory.create(page_data=course1_flow_page_data)) + cls.course1_session_count = 1 + cls.course1_visits_count = 1 + cls.course1_event = (factories.EventFactory.create( + course=cls.course1, kind="course1_kind")) + cls.course1_event_count = 1 + + cls.course2_sessions = factories.FlowSessionFactory.create_batch( + size=3, participation=cls.course2_student_participation) + cls.course2_session_count = 3 + for session in cls.course2_sessions: + course2_flow_page_data = factories.FlowPageDataFactory.create( + flow_session=session + ) + factories.FlowPageVisitFactory.create(page_data=course2_flow_page_data) + cls.course2_visits = models.FlowPageVisit.objects.filter( + flow_session__course=cls.course2) + cls.course2_visits_count = cls.course2_visits.count() + + cls.course2_events = factories.EventFactory.create_batch( + size=5, course=cls.course2, kind="course2_kind") + cls.course2_event_count = len(cls.course2_events) + + @classmethod + def get_admin_change_list_view_url(cls, model_name): + return super(CourseAdminTest, cls).get_admin_change_list_view_url( + app_name="course", model_name=model_name.lower()) + + @classmethod + def get_admin_change_view_url(cls, model_name, args=None): + return super(CourseAdminTest, cls).get_admin_change_view_url( + app_name="course", model_name=model_name.lower(), args=args) + + def setUp(self): + super(CourseAdminTest, self).setUp() + self.superuser.refresh_from_db() + self.rf = RequestFactory() + + def list_filter_result(self, model_class, model_admin_class, + expected_counts_dict): + modeladmin = model_admin_class(model_class, site) + + for user in [self.superuser, self.instructor1, self.instructor2]: + with self.subTest(user=user): + request = self.rf.get( + self.get_admin_change_list_view_url(model_class.__name__), {}) + request.user = user + changelist = self.get_changelist(request, model_class, modeladmin) + + filterspec_list = self.get_filterspec_list(request, changelist) + queryset = changelist.get_queryset(request) + + if request.user == self.superuser: + self.assertIn( + ('All', self.course1.identifier, self.course2.identifier), + filterspec_list) + self.assertEqual(queryset.count(), expected_counts_dict["all"]) + elif request.user == self.instructor1: + self.assertNotIn( + ('All', self.course1.identifier, self.course2.identifier), + filterspec_list) + self.assertNotIn( + ('All', self.course2.identifier), filterspec_list) + self.assertNotIn( + (self.course2.identifier, ), filterspec_list) + self.assertEqual(queryset.count(), + expected_counts_dict["course1"]) + else: + assert request.user == self.instructor2 + self.assertNotIn( + ('All', self.course1.identifier, self.course2.identifier), + filterspec_list) + self.assertNotIn( + ('All', self.course1.identifier), filterspec_list) + self.assertNotIn( + (self.course1.identifier, ), filterspec_list) + self.assertEqual(queryset.count(), + expected_counts_dict["course2"]) + + @skipIf(six.PY2, "PY2 doesn't support subTest") + def test_flowsession_filter_result(self): + self.list_filter_result( + models.FlowSession, admin.FlowSessionAdmin, + expected_counts_dict={ + "all": (self.course1_session_count + self.course2_session_count), + "course1": self.course1_session_count, + "course2": self.course2_session_count + }) + + @skipIf(six.PY2, "PY2 doesn't support subTest") + def test_event_filter_result(self): + self.list_filter_result( + models.Event, admin.EventAdmin, + expected_counts_dict={ + "all": (self.course1_event_count + self.course2_event_count), + "course1": self.course1_event_count, + "course2": self.course2_event_count + }) + + @skipIf(six.PY2, "PY2 doesn't support subTest") + def test_participation_filter_result(self): + self.list_filter_result( + models.Participation, admin.ParticipationAdmin, + expected_counts_dict={ + "all": models.Participation.objects.count(), + "course1": + models.Participation.objects.filter(course=self.course1).count(), + "course2": + models.Participation.objects.filter(course=self.course2).count(), + }) + + @skipIf(six.PY2, "PY2 doesn't support subTest") + def test_flowpagevisit_filter_result(self): + self.list_filter_result( + models.FlowPageVisit, admin.FlowPageVisitAdmin, + expected_counts_dict={ + "all": self.course1_visits_count + self.course2_visits_count, + "course1": self.course1_visits_count, + "course2": self.course2_visits_count + }) + + @skipIf(six.PY2, "PY2 doesn't support subTest") + def test_flowpagevisit_flow_id_filter_result(self): + modeladmin = admin.FlowPageVisitAdmin(models.FlowPageVisit, site) + + for user in [self.superuser, self.instructor1, self.instructor2]: + with self.subTest(user=user): + request = self.rf.get( + self.get_admin_change_list_view_url("FlowPageVisit"), {}) + request.user = user + changelist = self.get_changelist( + request, models.FlowPageVisit, modeladmin) + + filterspec_list = self.get_filterspec_list(request, changelist) + + if request.user == self.superuser: + self.assertIn( + ('All', "001-linalg-recap", "quiz-test"), + filterspec_list) + elif request.user == self.instructor1: + self.assertNotIn( + ('All', "001-linalg-recap", "quiz-test"), + filterspec_list) + self.assertNotIn( + ('All', "quiz-test"), filterspec_list) + self.assertNotIn( + ("quiz-test", ), filterspec_list) + else: + assert request.user == self.instructor2 + self.assertNotIn( + ('All', "001-linalg-recap", "quiz-test"), + filterspec_list) + self.assertNotIn( + ('All', "001-linalg-recap"), filterspec_list) + self.assertNotIn( + ("001-linalg-recap", ), filterspec_list) + + def test_flowpagevisit_get_queryset_by_flow_id_filter(self): + modeladmin = admin.FlowPageVisitAdmin(models.FlowPageVisit, site) + + request = self.rf.get( + self.get_admin_change_list_view_url("FlowPageVisit"), + {"flow_id": "001-linalg-recap"}) + request.user = self.instructor1 + changelist = self.get_changelist( + request, models.FlowPageVisit, modeladmin) + + queryset = changelist.get_queryset(request) + self.assertEqual(queryset.count(), self.course1_visits_count) + +# vim: foldmethod=marker