From a9b7b536c497582e01bc97c81a6771cff90bb903 Mon Sep 17 00:00:00 2001 From: Payam Date: Sun, 8 Feb 2026 14:52:03 +0400 Subject: [PATCH 1/2] Add dotenv to dev dependencies --- poetry.lock | 33 +++++++++++++++++++++++++++++++++ pyproject.toml | 3 ++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 69d87643..0c182818 100644 --- a/poetry.lock +++ b/poetry.lock @@ -165,6 +165,21 @@ files = [ {file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"}, ] +[[package]] +name = "click" +version = "8.3.1" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, + {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + [[package]] name = "colorama" version = "0.4.6" @@ -1236,6 +1251,24 @@ files = [ [package.dependencies] six = ">=1.5" +[[package]] +name = "python-dotenv" +version = "1.2.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"}, + {file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"}, +] + +[package.dependencies] +click = {version = ">=5.0", optional = true, markers = "extra == \"cli\""} + +[package.extras] +cli = ["click (>=5.0)"] + [[package]] name = "python-json-logger" version = "4.0.0" diff --git a/pyproject.toml b/pyproject.toml index 49bb3087..62be7b79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,8 @@ dev = [ "bandit (>=1.8.6,<2.0.0)", "pytest-cov (>=7.0.0,<8.0.0)", "freezegun (>=1.5.5,<2.0.0)", - "python-json-logger (>=4.0.0,<5.0.0)" + "python-json-logger (>=4.0.0,<5.0.0)", + "python-dotenv[cli] (>=1.2.1,<2.0.0)" ] [build-system] From 243285e5fa4d44b302cd6083d5aef854d0b5a1e9 Mon Sep 17 00:00:00 2001 From: Payam Date: Sun, 8 Feb 2026 18:53:35 +0400 Subject: [PATCH 2/2] feat: #142 #168 manage organiztion users --- django_email_learning/decorators.py | 9 +- ...salt_alter_imapconnection_salt_and_more.py | 39 +++++ django_email_learning/models.py | 3 + .../platform/api/serializers.py | 42 +++++- django_email_learning/platform/api/urls.py | 12 ++ django_email_learning/platform/api/views.py | 116 ++++++++++++++- django_email_learning/platform/urls.py | 6 + django_email_learning/platform/views.py | 25 +++- .../templates/emails/password_reset.html | 20 +++ .../templates/emails/password_reset.txt | 16 ++ .../templates/platform/base.html | 1 + .../templates/platform/course.html | 2 +- .../templates/platform/organization.html | 29 ++++ django_service/urls.py | 26 ++++ docs/source/platform/organizations.rst | 22 +-- .../platform/organization/Organization.jsx | 129 ++++++++++++++++ .../components/DeleteUserDialog.jsx | 65 ++++++++ .../organization/components/UserForm.jsx | 139 ++++++++++++++++++ frontend/platform/organization/index.html | 12 ++ .../platform/organizations/Organizations.jsx | 4 +- frontend/src/components/MenuBar.jsx | 2 +- .../test_get_or_create_user_view.py | 56 +++++++ .../test_views/test_organization_user_api.py | 50 +++++++ 23 files changed, 805 insertions(+), 20 deletions(-) create mode 100644 django_email_learning/migrations/0007_alter_apikey_salt_alter_imapconnection_salt_and_more.py create mode 100644 django_email_learning/templates/emails/password_reset.html create mode 100644 django_email_learning/templates/emails/password_reset.txt create mode 100644 django_email_learning/templates/platform/organization.html create mode 100644 frontend/platform/organization/Organization.jsx create mode 100644 frontend/platform/organization/components/DeleteUserDialog.jsx create mode 100644 frontend/platform/organization/components/UserForm.jsx create mode 100644 frontend/platform/organization/index.html create mode 100644 tests/platform/api/test_views/test_get_or_create_user_view.py diff --git a/django_email_learning/decorators.py b/django_email_learning/decorators.py index bd0a877c..1b5d41ea 100644 --- a/django_email_learning/decorators.py +++ b/django_email_learning/decorators.py @@ -51,7 +51,7 @@ def _wrapped_view(request, *view_args, **view_kwargs) -> JsonResponse: # type: return decorator -def is_an_organization_member() -> typing.Callable: +def is_an_organization_member(only_admin: bool = False) -> typing.Callable: def decorator(view_func: typing.Callable) -> typing.Callable: @wraps(view_func) def _wrapped_view(request, *view_args, **view_kwargs) -> JsonResponse: # type: ignore[no-untyped-def] @@ -60,9 +60,12 @@ def _wrapped_view(request, *view_args, **view_kwargs) -> JsonResponse: # type: return JsonResponse({"error": "Unauthorized"}, status=401) if not user.is_superuser: - has_access = OrganizationUser.objects.filter( # type: ignore[misc] + qs = OrganizationUser.objects.filter( # type: ignore[misc] user=user - ).exists() + ) + if only_admin: + qs = qs.filter(role="admin") + has_access = qs.exists() if not has_access: return JsonResponse({"error": "Forbidden"}, status=403) return view_func(request, *view_args, **view_kwargs) diff --git a/django_email_learning/migrations/0007_alter_apikey_salt_alter_imapconnection_salt_and_more.py b/django_email_learning/migrations/0007_alter_apikey_salt_alter_imapconnection_salt_and_more.py new file mode 100644 index 00000000..9eb6fcea --- /dev/null +++ b/django_email_learning/migrations/0007_alter_apikey_salt_alter_imapconnection_salt_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 6.0.2 on 2026-02-08 14:18 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "django_email_learning", + "0006_alter_apikey_salt_alter_imapconnection_salt_and_more", + ), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="apikey", + name="salt", + field=models.CharField( + default="647b192f563e4a47a23d9cd7d7331e1b", + editable=False, + max_length=32, + ), + ), + migrations.AlterField( + model_name="imapconnection", + name="salt", + field=models.CharField( + default="647b192f563e4a47a23d9cd7d7331e1b", + editable=False, + max_length=32, + ), + ), + migrations.AlterUniqueTogether( + name="organizationuser", + unique_together={("user", "organization")}, + ), + ] diff --git a/django_email_learning/models.py b/django_email_learning/models.py index 784f5e5c..81aefce9 100644 --- a/django_email_learning/models.py +++ b/django_email_learning/models.py @@ -106,6 +106,9 @@ class OrganizationUser(models.Model): def __str__(self) -> str: return f"{self.user.username} - {self.organization.name}" + class Meta: + unique_together = [["user", "organization"]] + class EncryptionMixin(models.Model): salt = models.CharField(max_length=32, editable=False, default=uuid.uuid4().hex) diff --git a/django_email_learning/platform/api/serializers.py b/django_email_learning/platform/api/serializers.py index db374fe7..49d77c74 100644 --- a/django_email_learning/platform/api/serializers.py +++ b/django_email_learning/platform/api/serializers.py @@ -1,3 +1,4 @@ +import re from pydantic import ( BaseModel, ConfigDict, @@ -23,6 +24,7 @@ QuizSelectionStrategy, Enrollment, EnrollmentStatus, + OrganizationUser, ) from django_email_learning.services.jwt_service import generate_jwt import enum @@ -52,6 +54,25 @@ def from_django_model(api_key: ApiKey) -> "ApiKeyResponse": ) +class GetOrCreateUserRequest(BaseModel): + email: str = Field(min_length=1, examples=["user@example.com"]) + organization_id: int = Field(gt=0, examples=[1]) + + @field_validator("email") + def validate_email(cls, email: str) -> str: + email_regex = r"^[\w\.-]+@[\w\.-]+\.\w+$" + if not re.match(email_regex, email): + raise ValueError("Invalid email format") + return email + + +class UserResponse(BaseModel): + id: int + email: str + + model_config = ConfigDict(from_attributes=True) + + class CreateCourseRequest(BaseModel): title: str = Field(min_length=1, examples=["Introduction to Python"]) slug: str = Field( @@ -273,8 +294,8 @@ class UpdateOrganizationRequest(BaseModel): class UserRole(enum.StrEnum): ADMIN = "admin" - Editor = "editor" - Viewer = "viewer" + EDITOR = "editor" + VIEWER = "viewer" class AddOrganizationUserRequest(BaseModel): @@ -282,11 +303,26 @@ class AddOrganizationUserRequest(BaseModel): role: UserRole = Field(min_length=1, examples=[UserRole.ADMIN]) +class UpdateOrganizationUserRoleRequest(BaseModel): + role: UserRole = Field(min_length=1, examples=[UserRole.ADMIN]) + + class OrganizationUserResponse(BaseModel): + id: int user_id: int + organization_id: int + email: str role: UserRole - model_config = ConfigDict(from_attributes=True) + @staticmethod + def from_django_model(org_user: OrganizationUser) -> "OrganizationUserResponse": + return OrganizationUserResponse( + id=org_user.id, + user_id=org_user.user.id, + organization_id=org_user.organization.id, + email=org_user.user.email, + role=UserRole(org_user.role), + ) class UpdateSessionRequest(BaseModel): diff --git a/django_email_learning/platform/api/urls.py b/django_email_learning/platform/api/urls.py index 924dc50d..69c67918 100644 --- a/django_email_learning/platform/api/urls.py +++ b/django_email_learning/platform/api/urls.py @@ -2,6 +2,7 @@ from django.views.defaults import page_not_found from django_email_learning.platform.api.views import ( ApiKeyView, + GetOrCreateUserByEmail, SingleApiKeyView, CourseView, EnrollmentView, @@ -10,6 +11,7 @@ ImapConnectionView, OrganizationsView, OrganizationUsersView, + SingleOrganizationUserView, SingleOrganizationView, SingleCourseView, CourseContentView, @@ -34,6 +36,11 @@ OrganizationUsersView.as_view(), name="organization_users_view", ), + path( + "organizations//users//", + SingleOrganizationUserView.as_view(), + name="single_organization_user_view", + ), path( "organizations//imap-connections/", ImapConnectionView.as_view(), @@ -97,6 +104,11 @@ SingleApiKeyView.as_view(), name="single_api_key_view", ), + path( + "users/get-or-create-by-email/", + GetOrCreateUserByEmail.as_view(), + name="get_or_create_user_by_email_view", + ), path("session", UpdateSessionView.as_view(), name="update_session_view"), path("", page_not_found, name="root"), ] diff --git a/django_email_learning/platform/api/views.py b/django_email_learning/platform/api/views.py index 24bde874..5547332f 100644 --- a/django_email_learning/platform/api/views.py +++ b/django_email_learning/platform/api/views.py @@ -10,6 +10,8 @@ from django.conf import settings from django.core.files.storage import default_storage from django.utils import timezone +from django.contrib.auth.models import User +from django.contrib.auth.forms import PasswordResetForm from datetime import timedelta, datetime from pydantic import ValidationError from enum import StrEnum @@ -34,6 +36,7 @@ is_platform_admin, ) from typing import Any +import uuid import json import logging @@ -417,6 +420,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt @method_decorator(accessible_for(roles={"admin"}), name="post") +@method_decorator(accessible_for(roles={"admin"}), name="get") class OrganizationUsersView(View): def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] try: @@ -430,7 +434,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt ) org_user.save() return JsonResponse( - serializers.OrganizationUserResponse.model_validate( + serializers.OrganizationUserResponse.from_django_model( org_user ).model_dump(), status=201, @@ -442,9 +446,64 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt except IntegrityError as e: return JsonResponse({"error": str(e)}, status=409) + def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] + organization_users = OrganizationUser.objects.filter( + organization_id=kwargs["organization_id"] + ) + response_list = [] + for org_user in organization_users: + response_list.append( + serializers.OrganizationUserResponse.from_django_model( + org_user + ).model_dump() + ) + return JsonResponse({"organization_users": response_list}, status=200) + + +@method_decorator(accessible_for(roles={"admin"}), name="delete") +class SingleOrganizationUserView(View): + def delete(self, request, *args, **kwargs): # type: ignore[no-untyped-def] + try: + org_user = OrganizationUser.objects.get(id=kwargs["user_id"]) + org_user.delete() + return JsonResponse( + {"message": "Organization user removed successfully"}, status=200 + ) + except OrganizationUser.DoesNotExist: + return JsonResponse({"error": "Organization user not found"}, status=404) + except ValidationError as e: + return JsonResponse({"error": e.json()}, status=400) + except IntegrityError as e: + return JsonResponse({"error": str(e)}, status=409) + + def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] + try: + payload = json.loads(request.body) + serializer = serializers.UpdateOrganizationUserRoleRequest.model_validate( + payload + ) + org_user = OrganizationUser.objects.get( + organization_id=kwargs["organization_id"], user_id=kwargs["user_id"] + ) + org_user.role = serializer.role + org_user.save() + return JsonResponse( + serializers.OrganizationUserResponse.from_django_model( + org_user + ).model_dump(), + status=200, + ) + except OrganizationUser.DoesNotExist: + return JsonResponse({"error": "Organization user not found"}, status=404) + except ValidationError as e: + return JsonResponse({"error": e.json()}, status=400) + except IntegrityError as e: + return JsonResponse({"error": str(e)}, status=409) + @method_decorator(is_platform_admin(), name="post") @method_decorator(is_platform_admin(), name="delete") +@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get") class SingleOrganizationView(View): def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] try: @@ -488,6 +547,61 @@ def delete(self, request, *args, **kwargs): # type: ignore[no-untyped-def] except IntegrityError as e: return JsonResponse({"error": str(e)}, status=409) + def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] + try: + organization = Organization.objects.get(id=kwargs["organization_id"]) + return JsonResponse( + serializers.OrganizationResponse.from_django_model( + organization, + request.build_absolute_uri, + ).model_dump(), + status=200, + ) + except Organization.DoesNotExist: + return JsonResponse({"error": "Organization not found"}, status=404) + except ValidationError as e: + return JsonResponse({"error": e.json()}, status=400) + + +@method_decorator((is_an_organization_member(only_admin=True)), name="post") +class GetOrCreateUserByEmail(View): + def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] + payload = json.loads(request.body) + serializer = serializers.GetOrCreateUserRequest.model_validate(payload) + try: + email = serializer.email + organization_id = serializer.organization_id + user = User.objects.filter(email=email).first() + if not user: + user = User.objects.create_user( + username=email, email=email, password=uuid.uuid4().hex + ) + form = PasswordResetForm(data={"email": email}) + if form.is_valid(): + form.save( + request=request, + use_https=True, + from_email=settings.DJANGO_EMAIL_LEARNING["FROM_EMAIL"], + email_template_name="emails/password_reset.txt", + html_email_template_name="emails/password_reset.html", + extra_email_context={ + "organization": Organization.objects.get( + id=organization_id + ).name + }, + ) + else: + raise ValueError( + "Failed to send password reset email to the new user." + ) + return JsonResponse( + serializers.UserResponse.model_validate(user).model_dump(), status=200 + ) + except ValidationError as e: + return JsonResponse({"error": e.json()}, status=400) + except IntegrityError as e: + return JsonResponse({"error": str(e)}, status=409) + @method_decorator(accessible_for(roles={"admin", "editor"}), name="post") class FileView(View): diff --git a/django_email_learning/platform/urls.py b/django_email_learning/platform/urls.py index 6d562de3..9fddf432 100644 --- a/django_email_learning/platform/urls.py +++ b/django_email_learning/platform/urls.py @@ -4,6 +4,7 @@ CourseView, Courses, Organizations, + SingleOrganization, Learners, ApiKeys, ) @@ -14,6 +15,11 @@ path("courses/", Courses.as_view(), name="courses_view"), path("courses//", CourseView.as_view(), name="course_detail_view"), path("organizations/", Organizations.as_view(), name="organizations_view"), + path( + "organizations//", + SingleOrganization.as_view(), + name="organization_detail_view", + ), path("learners/", Learners.as_view(), name="learners_view"), path("settings/api_keys/", ApiKeys.as_view(), name="api_keys_view"), path( diff --git a/django_email_learning/platform/views.py b/django_email_learning/platform/views.py index 14e8217a..d33a96f4 100644 --- a/django_email_learning/platform/views.py +++ b/django_email_learning/platform/views.py @@ -43,6 +43,14 @@ def get_shared_context(self) -> Dict[str, Any]: and getattr(self.request.user, "has_platform_admin_role", False) ) ), + "is_organization_admin": ( + self.request.user.is_superuser + or ( + OrganizationUser.objects.filter( + user=self.request.user, role="admin" + ).exists() # type: ignore[misc] + ) + ), } def get_or_set_active_organization(self) -> str: @@ -89,7 +97,7 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def] @method_decorator(login_required, name="dispatch") -@method_decorator(is_platform_admin(), name="dispatch") +@method_decorator(is_an_organization_member(only_admin=True), name="dispatch") class Organizations(BasePlatformView): template_name = "platform/organizations.html" @@ -99,6 +107,21 @@ def get_context_data(self, **kwargs): # type: ignore[no-untyped-def] return context +@method_decorator(login_required, name="dispatch") +@method_decorator(is_an_organization_member(only_admin=True), name="dispatch") +class SingleOrganization(BasePlatformView): + template_name = "platform/organization.html" + + def get_context_data(self, **kwargs): # type: ignore[no-untyped-def] + context = super().get_context_data(**kwargs) + organization = Organization.objects.get(pk=self.kwargs["organization_id"]) + context["organization"] = organization + context["page_title"] = _("Organization: %(name)s") % { + "name": organization.name + } + return context + + @method_decorator(login_required, name="dispatch") @method_decorator(is_platform_admin(), name="dispatch") class Learners(BasePlatformView): diff --git a/django_email_learning/templates/emails/password_reset.html b/django_email_learning/templates/emails/password_reset.html new file mode 100644 index 00000000..90e875fa --- /dev/null +++ b/django_email_learning/templates/emails/password_reset.html @@ -0,0 +1,20 @@ +{% extends "emails/base.html" %} +{% load i18n %} + +{% block content %} +

{% blocktranslate %}Welcome to {{ organization }}{% endblocktranslate %}

+{% translate "Hi there," %}

+ +{% blocktranslate %} +You have been added as a user to the {{ organization }} platform.
+To get started and set up your account, please click the link below to choose your password.
+{% endblocktranslate %} + +{% block reset_link %} +{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %} +{% endblock %}

+ +{% translate "If you weren't expecting this invitation, you can safely ignore this email." %}

+{% translate "Best regards," %}
+{% blocktranslate %}The {{ organization }} team{% endblocktranslate %} +{% endblock %} diff --git a/django_email_learning/templates/emails/password_reset.txt b/django_email_learning/templates/emails/password_reset.txt new file mode 100644 index 00000000..c6304dd0 --- /dev/null +++ b/django_email_learning/templates/emails/password_reset.txt @@ -0,0 +1,16 @@ +{% load i18n %} + +{% translate "Hi there," %} + +{% blocktranslate %} +You have been added as a user to the {{ organization }} platform. +To get started and set up your account, please follow the link below and choose your password +{% endblocktranslate %} + +{% block reset_link %} +{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %} +{% endblock %} + +{% translate "If you weren't expecting this invitation, you can safely ignore this email." %} +{% translate "Best regards," %} +{% blocktranslate %}The {{ organization }} team{% endblocktranslate %} diff --git a/django_email_learning/templates/platform/base.html b/django_email_learning/templates/platform/base.html index 088e313f..05164d6f 100644 --- a/django_email_learning/templates/platform/base.html +++ b/django_email_learning/templates/platform/base.html @@ -13,6 +13,7 @@ localStorage.setItem('platformBaseUrl', '{{ platform_base_url }}'); localStorage.setItem('userRole', '{{ user_role }}'); localStorage.setItem('isPlatformAdmin', {{ is_platform_admin|yesno:"true,false" }}); + localStorage.setItem('isOrganizationAdmin', {{ is_organization_admin|yesno:"true,false" }}); const direction = "{% if IS_RTL %}rtl{% else %}ltr{% endif %}"; let localeMessages = { "organizations": "{% translate 'Organizations' %}", diff --git a/django_email_learning/templates/platform/course.html b/django_email_learning/templates/platform/course.html index a00964ee..b3e341fe 100644 --- a/django_email_learning/templates/platform/course.html +++ b/django_email_learning/templates/platform/course.html @@ -4,7 +4,7 @@ {% load i18n %} + {% vite_asset 'platform/organization/Organization.jsx' %} +{% endblock %} diff --git a/django_service/urls.py b/django_service/urls.py index 1d98f1bb..f2707e23 100644 --- a/django_service/urls.py +++ b/django_service/urls.py @@ -32,6 +32,32 @@ auth_views.LoginView.as_view(template_name="admin/login.html"), name="login", ), + path( + "accounts/logout/", + auth_views.LogoutView.as_view(next_page="home"), + name="logout", + ), + path( + "accounts/password_reset/", + auth_views.PasswordResetView.as_view( + template_name="registration/password_reset_form.html", + email_template_name="registration/password_reset_email.html", + subject_template_name="registration/password_reset_subject.txt", + ), + name="password_reset", + ), + path( + "reset///", + auth_views.PasswordResetConfirmView.as_view(), + name="password_reset_confirm", + ), + path( + "reset/done/", + auth_views.PasswordResetCompleteView.as_view( + template_name="registration/password_reset_complete.html" + ), + name="password_reset_complete", + ), path( "email_learning/", include("django_email_learning.urls", namespace="django_email_learning"), diff --git a/docs/source/platform/organizations.rst b/docs/source/platform/organizations.rst index adaa88d4..efa5ca19 100644 --- a/docs/source/platform/organizations.rst +++ b/docs/source/platform/organizations.rst @@ -118,18 +118,22 @@ Organizations support role-based access control with three permission levels: - Cannot create or modify any content - Cannot access organization settings -Assigning User Roles -~~~~~~~~~~~~~~~~~~~~ +Creating Organization Users +~~~~~~~~~~~~~~~~~~~~~~~~~~~ +To add a user to an organization: +1. Open the organization details page by clicking its name in the organizations list. +2. You will see the "Organization Users" list, and also a button to add new users. +3. Click the "Add User" button to open the user creation form. +4. Enter the user's email address and select their role (Admin, Editor, Viewer). +5. Click "Add User" to create the user and send them an invitation email with the instruction to set their password. -Organization users are currently managed through the Django admin panel: +.. note:: + If the email address already exists in the system, the existing user will be added to the organization with the specified role. -1. Access Django Admin at ``/admin/`` -2. Navigate to **Django Email Learning > Organization Users** -3. Create a new Organization User entry -4. Select the user, organization, and assign appropriate role +.. important:: + Django email learning uses Django Auth built-in URLs and views for password resets. Ensure that you have configured those URLs and views correctly in your Django project settings. + please check the official Django documentation for more details: https://docs.djangoproject.com/en/6.0/topics/auth/default/#module-django.contrib.auth.views -.. note:: - Future versions will include a user-friendly interface for managing organization users directly from the platform. Public Organization Pages ------------------------- diff --git a/frontend/platform/organization/Organization.jsx b/frontend/platform/organization/Organization.jsx new file mode 100644 index 00000000..55e53e7c --- /dev/null +++ b/frontend/platform/organization/Organization.jsx @@ -0,0 +1,129 @@ +import render from "../../src/render"; +import { lazy, Suspense, use } from "react"; +import Base from "../../src/components/Base"; +import Box from "@mui/material/Box"; +import Grid from "@mui/material/Grid"; +import IconButton from "@mui/material/IconButton"; +import TableContainer from "@mui/material/TableContainer"; +import Table from "@mui/material/Table"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import Typography from "@mui/material/Typography"; +import LinearProgress from "@mui/material/LinearProgress"; +import Dialog from "@mui/material/Dialog"; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import { getCookie } from "../../src/utils.js"; +import { useState, useEffect } from "react"; +import { Button } from "@mui/material"; + +const UserForm = lazy(() => import("./components/UserForm.jsx")); +const DeleteUserDialog = lazy(() => import("./components/DeleteUserDialog.jsx")); +const platformBaseUrl = localStorage.getItem('platformBaseUrl'); +const apiBaseUrl = localStorage.getItem('apiBaseUrl'); +const userRole = localStorage.getItem('userRole'); + +function Organization() { + const [organization, setOrganization] = useState(null); + const [organizationUsers, setOrganizationUsers] = useState([]); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogContent, setDialogContent] = useState(null); + + const refreshUsers = () => { + fetch(`${apiBaseUrl}/organizations/${organizationId}/users/`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken'), + }, + }) + .then(response => response.json()) + .then(data => { + setOrganizationUsers(data.organization_users); + }) + .catch(error => { + console.error('Error fetching organization users:', error); + }); + }; + + useEffect(() => { + + fetch(`${apiBaseUrl}/organizations/${organizationId}/`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken'), + }, + }) + .then(response => response.json()) + .then(data => { + setOrganization(data); + }) + .catch(error => { + console.error('Error fetching organization:', error); + }); + + refreshUsers(); + }, []); + + const showEditUserDialog = (user) => { + setDialogContent( + }> + setDialogOpen(false)} refreshUsers={refreshUsers} user={user} /> + + ); + setDialogOpen(true); + }; + + return ( + + + + { organizationUsers.length > 0 ? + + + + {localeMessages["user"]} + {localeMessages["role"]} + {userRole !== 'viewer' && {localeMessages["actions"]}} + + + + {organizationUsers.map((user) => ( + + {user.email} + {user.role} + {userRole !== 'viewer' && + { + showEditUserDialog(user);}}> + { + setDialogContent(}> + setDialogOpen(false)} handleSuccess={() => { refreshUsers(); setDialogOpen(false); }} /> + ); + setDialogOpen(true); + }}> + } + + ))} + +
+
: {localeMessages["no_users_in_organization"]} } +
+
+ setDialogOpen(false)} fullWidth maxWidth="sm"> + {dialogContent} + + ); +} + +render({children: }); diff --git a/frontend/platform/organization/components/DeleteUserDialog.jsx b/frontend/platform/organization/components/DeleteUserDialog.jsx new file mode 100644 index 00000000..385f399e --- /dev/null +++ b/frontend/platform/organization/components/DeleteUserDialog.jsx @@ -0,0 +1,65 @@ +import { Alert, Button, Box, DialogActions, DialogContent, DialogContentText, DialogTitle, Typography } from "@mui/material"; +import { useState } from "react" +import { getCookie } from '../../../src/utils'; +import WarningIcon from '@mui/icons-material/Warning'; + + +const DeleteUserDialog = ({ user, handleClose, handleSuccess}) => { + + const apiBaseUrl = localStorage.getItem('apiBaseUrl'); + const [errorMessage, setErrorMessage] = useState(""); + + const deleteUser = () => { + fetch(`${apiBaseUrl}/organizations/${user.organization_id}/users/${user.id}/`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + }) + .then(response => { + if(response.status != 200) { + throw new Error("Unhandled network Error! user is not deleted") + } + return response.json()} + ) + .then(data => { + if (data.error){ + throw new Error(data.error) + } else { + console.log('User deleted successfully:', data); + handleSuccess(); + } + }) + .catch(error => { + setErrorMessage(error.message) + }); + } + + + return <> + { errorMessage && {errorMessage} } + + + + + + {localeMessages["delete_user_with_email"].replace("USER_EMAIL", user.email)} + + + + + + { localeMessages["user_delete_confirmation"].replace("USER_EMAIL", user.email) } + + {localeMessages["delete_note"]} + + + + + ; +} + +export default DeleteUserDialog; diff --git a/frontend/platform/organization/components/UserForm.jsx b/frontend/platform/organization/components/UserForm.jsx new file mode 100644 index 00000000..4a312e69 --- /dev/null +++ b/frontend/platform/organization/components/UserForm.jsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import Box from '@mui/material/Box'; +import TextField from '@mui/material/TextField'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import { getCookie } from '../../../src/utils.js'; + +const apiBaseUrl = localStorage.getItem('apiBaseUrl'); + + + + +const UserForm = ({ onClose, organizationId, refreshUsers, user = null }) => { + const [email, setEmail] = useState(user ? user.email : ''); + const [role, setRole] = useState(user ? user.role : 'viewer'); + const [error, setError] = useState(''); + + const createUser = (id) => { + fetch(`${apiBaseUrl}/organizations/${organizationId}/users/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken'), + }, + body: JSON.stringify({ 'user_id': id, 'role': role }), + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to add user to organization'); + } + return response.json(); + }) + .then(data => { + refreshUsers(); + onClose(); + }) + .catch(error => { + console.error('Error adding user to organization:', error); + setError(localeMessages["failed_to_add_user"]); + }); + } + + const handleSubmit = (event) => { + event.preventDefault(); + if (user) { + updateUser(); + } else { + addUser(); + } + + }; + + const addUser = () => { + fetch(`${apiBaseUrl}/users/get-or-create-by-email/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken'), + }, + body: JSON.stringify({ 'email': email, 'organization_id': organizationId }), + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to get or create user by email'); + } + return response.json(); + }) + .then(data => { + const userId = data.id; + createUser(userId); + }) + .catch(error => { + console.error('Error getting or creating user:', error); + setError(localeMessages["failed_to_get_or_create_user"]); + }); + } + + const updateUser = () => { + console.log('Updating user:', user); + fetch(`${apiBaseUrl}/organizations/${organizationId}/users/${user.user_id}/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken'), + }, + body: JSON.stringify({ 'role': role }), + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to update user role'); + } + return response.json(); + }) + .then(data => { + refreshUsers(); + onClose(); + }) + .catch(error => { + console.error('Error updating user role:', error); + setError(localeMessages["failed_to_update_user_role"]); + }); + } + + return ( + + { user ? localeMessages["change_user_role"] : localeMessages["add_users_to_organization"]} + setEmail(e.target.value)} + required + disabled={!!user} + /> + + {localeMessages["role"]} + + + {error && {error}} + + + ); +}; + +export default UserForm; diff --git a/frontend/platform/organization/index.html b/frontend/platform/organization/index.html new file mode 100644 index 00000000..3f1ecedc --- /dev/null +++ b/frontend/platform/organization/index.html @@ -0,0 +1,12 @@ + + + + + + Organization + + +
+ + + diff --git a/frontend/platform/organizations/Organizations.jsx b/frontend/platform/organizations/Organizations.jsx index ef028799..894d0eef 100644 --- a/frontend/platform/organizations/Organizations.jsx +++ b/frontend/platform/organizations/Organizations.jsx @@ -6,6 +6,7 @@ import Dialog from "@mui/material/Dialog" import Grid from "@mui/material/Grid" import IconButton from "@mui/material/IconButton" import LinearProgress from "@mui/material/LinearProgress" +import Link from "@mui/material/Link" import Paper from "@mui/material/Paper" import TableContainer from "@mui/material/TableContainer" import Table from "@mui/material/Table" @@ -24,6 +25,7 @@ import render from "../../src/render"; import { lazy, Suspense } from "react"; const OrganizationForm = lazy(() => import("./components/OrganizationForm.jsx")); +const platformBaseUrl = localStorage.getItem('platformBaseUrl'); function Organizations() { const [dialogOpen, setDialogOpen] = useState(false); @@ -129,7 +131,7 @@ function Organizations() { { organizations.map((org) => ( - {org.name} + {org.name} goToUrl(org.public_url)}> { diff --git a/frontend/src/components/MenuBar.jsx b/frontend/src/components/MenuBar.jsx index 15591d7a..24fe6549 100644 --- a/frontend/src/components/MenuBar.jsx +++ b/frontend/src/components/MenuBar.jsx @@ -126,7 +126,7 @@ function MenuBar({activeOrganizationId, changeOrganizationCallback, showOrganiza let pages = [] - if (localStorage.getItem('isPlatformAdmin') == 'true') { + if (localStorage.getItem('isOrganizationAdmin') == 'true') { pages.push( { name: localeMessages["organizations"], icon: , href: platformBaseUrl + '/organizations/'}, ); diff --git a/tests/platform/api/test_views/test_get_or_create_user_view.py b/tests/platform/api/test_views/test_get_or_create_user_view.py new file mode 100644 index 00000000..9d9d4549 --- /dev/null +++ b/tests/platform/api/test_views/test_get_or_create_user_view.py @@ -0,0 +1,56 @@ +from django.urls import reverse +from django.core import mail +import pytest + +url = reverse("django_email_learning:api_platform:get_or_create_user_by_email_view") + + +@pytest.mark.parametrize( + "client", ["superadmin", "platform_admin"], indirect=["client"] +) +def test_get_or_create_user_view(client): + response = client.post( + url, + data={"email": "testuser@example.com", "organization_id": 1}, + content_type="application/json", + ) + assert response.status_code == 200 + data = response.json() + assert data["email"] == "testuser@example.com" + + assert len(mail.outbox) == 1 + email = mail.outbox[0] + assert email.to == ["testuser@example.com"] + + +@pytest.mark.parametrize("client", ["viewer", "editor"], indirect=["client"]) +def test_get_or_create_user_view_forbidden(client): + response = client.post( + url, + data={"email": "testuser@example.com", "organization_id": 1}, + content_type="application/json", + ) + assert response.status_code == 403 + + +def test_get_or_create_user_view_anonymous(anonymous_client): + response = anonymous_client.post( + url, + data={"email": "testuser@example.com", "organization_id": 1}, + content_type="application/json", + ) + assert response.status_code == 401 + + +def test_add_existing_user_to_organization(superadmin_client, users): + response = superadmin_client.post( + url, + data={"email": users["editor_user"].email, "organization_id": 1}, + content_type="application/json", + ) + assert response.status_code == 200 + data = response.json() + assert data["email"] == users["editor_user"].email + + # No new email should be sent when adding an existing user to the organization + assert len(mail.outbox) == 0 diff --git a/tests/platform/api/test_views/test_organization_user_api.py b/tests/platform/api/test_views/test_organization_user_api.py index b21dd2c2..c51ae951 100644 --- a/tests/platform/api/test_views/test_organization_user_api.py +++ b/tests/platform/api/test_views/test_organization_user_api.py @@ -8,6 +8,13 @@ ) +def get_single_user_url(organization_id, user_id): + return reverse( + "django_email_learning:api_platform:single_organization_user_view", + kwargs={"organization_id": organization_id, "user_id": user_id}, + ) + + def test_create_organization_user_as_superadmin(superadmin_client, second_user): response = superadmin_client.post( URL, @@ -118,3 +125,46 @@ def test_create_organization_user_in_nonexistent_organization( ) assert response.status_code == 404 assert "error" in response.json() + + +def test_delete_organization_user(superadmin_client, second_user): + # Create a new user and add them to the organization + response = superadmin_client.post( + URL, + data={ + "user_id": second_user.id, + "role": "editor", + }, + content_type="application/json", + ) + assert response.status_code == 201 + + # Get list of all users in the organization to find the ID of the newly added user + response = superadmin_client.get(URL) + assert response.status_code == 200 + data = response.json() + assert any(user["user_id"] == second_user.id for user in data["organization_users"]) + organization_user = next( + user for user in data["organization_users"] if user["user_id"] == second_user.id + ) + + # Now delete the user from the organization + delete_url = get_single_user_url(1, organization_user["id"]) + response = superadmin_client.delete(delete_url) + assert response.status_code == 200 + + # Verify the user is no longer in the organization + response = superadmin_client.get(URL) + assert response.status_code == 200 + data = response.json() + assert not any( + user["user_id"] == second_user.id for user in data["organization_users"] + ) + + +@pytest.mark.parametrize("client", ["viewer", "editor"], indirect=["client"]) +def test_other_roles_cannot_delete_organization_user(superadmin_client, client): + org_users = superadmin_client.get(URL).json()["organization_users"] + + delete_response = client.delete(get_single_user_url(1, org_users[0]["id"])) + assert delete_response.status_code == 403