Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 6.0.4 on 2026-04-29 07:18

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("django_email_learning", "0016_alter_jobexecution_job_name_and_more"),
]

operations = [
migrations.AlterField(
model_name="organizationuser",
name="role",
field=models.CharField(
choices=[
("admin", "Admin"),
("editor", "Editor"),
("instructor", "Instructor"),
("viewer", "Viewer"),
],
db_index=True,
max_length=50,
),
),
]
1 change: 1 addition & 0 deletions django_email_learning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ class OrganizationUser(models.Model):
choices=[
("admin", "Admin"),
("editor", "Editor"),
("instructor", "Instructor"),
("viewer", "Viewer"),
],
db_index=True,
Expand Down
1 change: 1 addition & 0 deletions django_email_learning/platform/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ class UpdateOrganizationRequest(BaseModel):
class UserRole(enum.StrEnum):
ADMIN = "admin"
EDITOR = "editor"
INSTRUCTOR = "instructor"
VIEWER = "viewer"


Expand Down
68 changes: 46 additions & 22 deletions django_email_learning/platform/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,10 @@


@method_decorator(ensure_csrf_cookie, name="get")
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
@method_decorator(
accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
)
class CourseView(View):
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
payload = json.loads(request.body)
Expand Down Expand Up @@ -114,8 +116,10 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
return JsonResponse({"courses": response_list}, status=200)


@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
@method_decorator(
accessible_for(roles={"admin", "editor", "viewer", "instructor"}), name="get"
)
class CourseContentView(View):
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
payload = json.loads(request.body)
Expand Down Expand Up @@ -161,7 +165,7 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
return JsonResponse({"error": "Course not found"}, status=404)


@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
class ReorderCourseContentView(View):
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
payload = json.loads(request.body)
Expand Down Expand Up @@ -207,9 +211,13 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
return JsonResponse({"error": str(e)}, status=409)


@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
@method_decorator(accessible_for(roles={"admin", "editor"}), name="delete")
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
@method_decorator(
accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
)
@method_decorator(
accessible_for(roles={"admin", "editor", "instructor"}), name="delete"
)
@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
class SingleCourseContentView(View):
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
try:
Expand Down Expand Up @@ -344,9 +352,13 @@ def _update_course_content_atomic(
)


@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
@method_decorator(accessible_for(roles={"admin", "editor"}), name="delete")
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
@method_decorator(
accessible_for(roles={"admin", "editor", "instructor"}), name="delete"
)
@method_decorator(
accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
)
class SingleCourseView(View):
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
try:
Expand Down Expand Up @@ -496,8 +508,10 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
return JsonResponse({"error": "Failed to enroll users"}, status=500)


@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
@method_decorator(
accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
)
class ImapConnectionView(View):
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
response_list = []
Expand Down Expand Up @@ -661,7 +675,9 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt

@method_decorator(is_platform_admin(), name="post")
@method_decorator(is_platform_admin(), name="delete")
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
@method_decorator(
accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
)
class SingleOrganizationView(View):
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
try:
Expand Down Expand Up @@ -771,7 +787,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
return JsonResponse({"error": str(e)}, status=409)


@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
class SendLessonToPlatformUser(View):
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
if not request.user.email:
Expand Down Expand Up @@ -802,8 +818,10 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
)


@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
@method_decorator(accessible_for(roles={"admin", "editor"}), name="delete")
@method_decorator(accessible_for(roles={"admin", "editor", "instructor"}), name="post")
@method_decorator(
accessible_for(roles={"admin", "editor", "instructor"}), name="delete"
)
class FileView(View):
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
uploaded_file = request.FILES.get("file")
Expand Down Expand Up @@ -891,7 +909,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
return JsonResponse(response_serializer.model_dump(), status=200)


@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
@method_decorator(accessible_for(roles={"admin", "instructor"}), name="get")
class LearnersView(PaginatedApiMixin, View):
def get_query_set(self, request: Any) -> models.QuerySet:
organization_id = self.kwargs["organization_id"]
Expand All @@ -913,7 +931,9 @@ def get_item_serializer_class(self) -> Any:
return serializers.LearnerResponse


@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
@method_decorator(
accessible_for(roles={"admin", "editor", "viewer", "instructor"}), name="get"
)
class SingleLearnerView(View):
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
try:
Expand Down Expand Up @@ -954,7 +974,7 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
return JsonResponse({"error": "An internal error occurred."}, status=500)


@method_decorator(accessible_for(roles={"admin"}), name="post")
@method_decorator(accessible_for(roles={"admin", "instructor"}), name="post")
class EnrollmentsView(PaginatedApiMixin, View):
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
payload = json.loads(request.body)
Expand Down Expand Up @@ -998,7 +1018,9 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
return JsonResponse({"error": e.json()}, status=400)


@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
@method_decorator(
accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
)
class EnrollmentView(View):
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
try:
Expand All @@ -1015,7 +1037,9 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
return JsonResponse({"error": e.json()}, status=400)


@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
@method_decorator(
accessible_for(roles={"admin", "editor", "instructor", "viewer"}), name="get"
)
class EnrollmentsStatisticsView(View):
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
course_id = kwargs["course_id"]
Expand Down
19 changes: 18 additions & 1 deletion django_email_learning/platform/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ def get_shared_context(self) -> Dict[str, Any]:
and getattr(self.request.user, "has_platform_admin_role", False)
)
),
"isInstructor": (
self.request.user.is_superuser
or (
OrganizationUser.objects.filter(
user=self.request.user, role="instructor"
).exists() # type: ignore[misc]
)
),
"aiTextEditingModel": AI_CONFIGURATIONS.get("TEXT_EDITING_MODEL"),
"customLogo": {
"horizontalLight": DJANGO_EMAIL_LEARNING_SETTINGS.get("LOGO", {})
Expand Down Expand Up @@ -478,7 +486,16 @@ def get_locale_messages(self) -> Dict[str, str]:
"role": _("Role"),
"admin": _("Admin"),
"editor": _("Editor"),
"instructor": _("Instructor"),
"viewer": _("Viewer"),
"viewer_role_description": _("Read-only access to course content."),
"editor_role_description": _("Can create and edit course content."),
"instructor_role_description": _(
"All editor permissions, plus access to learners and assignment review and approval."
),
"admin_role_description": _(
"Full access, including creating users and organizations."
),
"email": _("Email"),
"delete_user_with_email": _("Deleting USER_EMAIL"),
"user_delete_confirmation": _(
Expand All @@ -494,7 +511,7 @@ def get_locale_messages(self) -> Dict[str, str]:


@method_decorator(login_required, name="dispatch")
@method_decorator(is_an_organization_member(only_admin=True), name="dispatch")
@method_decorator(is_an_organization_member(), name="dispatch")
class Learners(BasePlatformView):
template_name = "platform/learners.html"

Expand Down
2 changes: 1 addition & 1 deletion frontend/platform/learners/Learners.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ function Learners(initialQs="") {
</Box>
<Box sx={{ p: 2 }}>
<Suspense fallback={<Box sx={{ p: 2 }}><LinearProgress /></Box>}>
<EnrollentList enrollments={enrollments} selectHandler={showEnrollmentStatus} />
{ enrollments && <EnrollentList enrollments={enrollments} selectHandler={showEnrollmentStatus} /> }
</Suspense>
</Box>
</Box>
Expand Down
15 changes: 15 additions & 0 deletions frontend/platform/organization/components/UserForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ const UserForm = ({ onClose, organizationId, refreshUsers, user = null }) => {
const [role, setRole] = useState(user ? user.role : 'viewer');
const [error, setError] = useState('');

const roleDescriptionByRole = {
viewer: localeMessages["viewer_role_description"],
editor: localeMessages["editor_role_description"],
instructor: localeMessages["instructor_role_description"],
admin: localeMessages["admin_role_description"],
};

const selectedRoleDescription = roleDescriptionByRole[role] || '';

const createUser = (id) => {
fetch(`${apiBaseUrl}/organizations/${organizationId}/users/`, {
method: 'POST',
Expand Down Expand Up @@ -123,9 +132,15 @@ const UserForm = ({ onClose, organizationId, refreshUsers, user = null }) => {
>
<MenuItem value="viewer">{localeMessages["viewer"]}</MenuItem>
<MenuItem value="editor">{localeMessages["editor"]}</MenuItem>
<MenuItem value="instructor">{localeMessages["instructor"]}</MenuItem>
<MenuItem value="admin">{localeMessages["admin"]}</MenuItem>
</Select>
</FormControl>
{selectedRoleDescription && (
<Typography variant="body2" color="text.secondary">
{selectedRoleDescription}
</Typography>
)}
{error && <Typography color="error">{error}</Typography>}
<Button type="submit" variant="contained" color="secondary">
{ user ? localeMessages["edit_user"] : localeMessages["add_user"]}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/MenuBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ function MenuBar({activeOrganizationId, changeOrganizationCallback, showOrganiza
const [menuOpen, setMenuOpen] = useState(false)
const [organizations, setOrganizations] = useState([])
const [deliverContentsJobStatus, setDeliverContentsJobStatus] = useState(null)
const { localeMessages, isPlatformAdmin, isOrganizationAdmin, direction, apiBaseUrl, platformBaseUrl, sidebarCustomComponent, customLogo } = useAppContext();
const { localeMessages, isPlatformAdmin, isOrganizationAdmin, isInstructor, direction, apiBaseUrl, platformBaseUrl, sidebarCustomComponent, customLogo } = useAppContext();

const theme = useTheme();
const isMdUpScreen = useMediaQuery(theme.breakpoints.up('md'));
Expand Down Expand Up @@ -137,7 +137,7 @@ function MenuBar({activeOrganizationId, changeOrganizationCallback, showOrganiza
}

pages.push({ name: localeMessages["course_management"], icon: <SchoolOutlinedIcon fontSize="small" />, href: platformBaseUrl + '/courses/' });
if (isOrganizationAdmin || isPlatformAdmin) {
if (isOrganizationAdmin || isPlatformAdmin || isInstructor) {
pages.push({ name: localeMessages["learners"], icon: <PeopleOutlinedIcon fontSize="small" />, href: platformBaseUrl + '/learners/' });
}
// pages.push({ name: 'Analytics', icon: <BarChartIcon fontSize="small" />, href: platformBaseUrl + '/analytics/' });
Expand Down
22 changes: 21 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,28 @@ def users(db):
email="orgadmin@example.com",
password="orgadminpass",
)
instructor_user = User(
id=6,
username="instructor",
email="instructor@example.com",
password="instructorpass",
)
User.objects.bulk_create(
[
superadmin,
editor_user,
platform_admin_user,
viewer_user,
organization_admin_user,
instructor_user,
]
)
group = Group.objects.get(name=PLATFORM_ADMIN_GROUP_NAME)
platform_admin_user.groups.add(group)
editor = OrganizationUser(user=editor_user, organization_id=1, role="editor")
instructor = OrganizationUser(
user=instructor_user, organization_id=1, role="instructor"
)
platform_admin = OrganizationUser(
user=platform_admin_user, organization_id=1, role="admin"
)
Expand All @@ -71,11 +81,12 @@ def users(db):
)
viewer = OrganizationUser(user=viewer_user, organization_id=1, role="viewer")
OrganizationUser.objects.bulk_create(
[editor, platform_admin, organization_admin, viewer]
[editor, instructor, platform_admin, organization_admin, viewer]
)
return {
"superadmin": superadmin,
"editor_user": editor_user,
"instructor_user": instructor_user,
"platform_admin": platform_admin_user,
"organization_admin": organization_admin_user,
"viewer_user": viewer_user,
Expand All @@ -101,6 +112,13 @@ def editor_client(users):
return client


@pytest.fixture()
def instructor_client(users):
client = Client()
client.force_login(users["instructor_user"])
return client


@pytest.fixture()
def platform_admin_client(users):
client = Client()
Expand All @@ -127,6 +145,7 @@ def client(
request,
superadmin_client,
editor_client,
instructor_client,
platform_admin_client,
viewer_client,
anonymous_client,
Expand All @@ -140,6 +159,7 @@ def _get_client(role_name):
"platform_admin": platform_admin_client,
"viewer": viewer_client,
"org_admin": org_admin_client,
"instructor": instructor_client,
}
return role_map.get(role_name)

Expand Down
Loading