diff --git a/django_email_learning/models.py b/django_email_learning/models.py index 2fc91b90..796a6431 100644 --- a/django_email_learning/models.py +++ b/django_email_learning/models.py @@ -7,6 +7,7 @@ from enum import StrEnum from typing import Any from django.conf import settings +from django.core.files.storage import default_storage from django.urls import reverse from django.db import models, transaction from django.core.validators import MaxValueValidator, MinValueValidator @@ -49,6 +50,19 @@ class Organization(models.Model): def __str__(self) -> str: return self.name + def replace_logo(self, file_path: str) -> str: + if default_storage.exists(file_path): + allowed_extensions = [".jpg", ".jpeg", ".png", ".svg"] + if not any(file_path.lower().endswith(ext) for ext in allowed_extensions): + raise ValueError("Logo must be an image file with a valid extension.") + final_path = f"organization_logos/{self.id}/{file_path.split('/')[-1]}" + default_storage.save(final_path, default_storage.open(file_path)) + self.logo = final_path + self.save() + return final_path + else: + raise ValueError("Logo file does not exist.") + class OrganizationUser(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="memberships") diff --git a/django_email_learning/platform/api/serializers.py b/django_email_learning/platform/api/serializers.py index 8b72775c..527a48ce 100644 --- a/django_email_learning/platform/api/serializers.py +++ b/django_email_learning/platform/api/serializers.py @@ -8,7 +8,6 @@ ) from datetime import datetime from typing import Optional, Literal, Any, Callable -from django.core.files.storage import default_storage from django.urls import reverse from django_email_learning.models import ( DeliveryStatus, @@ -196,16 +195,22 @@ def to_django_model(self) -> Organization: organization = Organization(name=self.name, description=self.description) organization.save() organization.refresh_from_db() - if self.logo and default_storage.exists(self.logo): - final_path = ( - f"organization_logos/{organization.id}/{self.logo.split('/')[-1]}" - ) - default_storage.save(final_path, default_storage.open(self.logo)) - organization.logo = final_path + if self.logo: + organization.replace_logo(self.logo) return organization +class UpdateOrganizationRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + name: Optional[str] = Field(None, min_length=1, examples=["AvaCode"]) + description: Optional[str] = Field( + None, examples=["A description of the organization."] + ) + logo: Optional[str] = Field(None, examples=["/path/to/logo.png"]) + remove_logo: Optional[bool] = Field(None, examples=[True]) + + class UpdateSessionRequest(BaseModel): active_organization_id: int = Field(examples=[1]) diff --git a/django_email_learning/platform/api/urls.py b/django_email_learning/platform/api/urls.py index 672c6d9a..da087966 100644 --- a/django_email_learning/platform/api/urls.py +++ b/django_email_learning/platform/api/urls.py @@ -3,9 +3,10 @@ from django_email_learning.platform.api.views import ( CourseView, EnrollmentView, - FileUploadView, + FileView, ImapConnectionView, OrganizationsView, + SingleOrganizationView, SingleCourseView, CourseContentView, ReorderCourseContentView, @@ -64,11 +65,16 @@ name="enrollment_view", ), path( - "organizations//file_upload/", - FileUploadView.as_view(), - name="file_upload_view", + "organizations//file/", + FileView.as_view(), + name="file_view", ), path("organizations/", OrganizationsView.as_view(), name="organizations_view"), + path( + "organizations//", + SingleOrganizationView.as_view(), + name="single_organization_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 e604b15c..5de12f43 100644 --- a/django_email_learning/platform/api/views.py +++ b/django_email_learning/platform/api/views.py @@ -401,15 +401,46 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt return JsonResponse({"error": str(e)}, status=409) +@method_decorator(is_platform_admin(), name="post") +class SingleOrganizationView(View): + def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] + try: + payload = json.loads(request.body) + serializer = serializers.UpdateOrganizationRequest.model_validate(payload) + organization = Organization.objects.get(id=kwargs["organization_id"]) + if serializer.name is not None: + organization.name = serializer.name + if serializer.description is not None: + organization.description = serializer.description + if serializer.logo is not None: + organization.logo = serializer.logo + if serializer.remove_logo: + organization.logo = None + organization.save() + 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) + except IntegrityError as e: + return JsonResponse({"error": str(e)}, status=409) + + @method_decorator(accessible_for(roles={"admin", "editor"}), name="post") -class FileUploadView(View): +class FileView(View): def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] uploaded_file = request.FILES.get("file") if not uploaded_file: return JsonResponse({"error": "No file uploaded"}, status=400) # check file extension - allowed_extensions = ["png", "jpg", "jpeg", "gif", "bmp", "svg"] + allowed_extensions = ["png", "jpg", "jpeg", "svg"] file_extension = uploaded_file.name.split(".")[-1].lower() if file_extension not in allowed_extensions: return JsonResponse({"error": "Invalid file type"}, status=400) diff --git a/frontend/platform/organizations/components/OrganizationForm.jsx b/frontend/platform/organizations/components/OrganizationForm.jsx index d2f284a9..24c61887 100644 --- a/frontend/platform/organizations/components/OrganizationForm.jsx +++ b/frontend/platform/organizations/components/OrganizationForm.jsx @@ -54,7 +54,7 @@ function OrganizationForm({ successCallback, failureCallback, cancelCallback, cr useEffect(() => { if (logoFile) { - fetch(`${apiBaseUrl}/organizations/1/file_upload/`, { + fetch(`${apiBaseUrl}/organizations/1/file/`, { method: 'POST', headers: { 'X-CSRFToken': getCookie('csrftoken'), diff --git a/tests/platform/api/test_views/test_organizations_view.py b/tests/platform/api/test_views/test_organizations_view.py index 5f92e958..5378b058 100644 --- a/tests/platform/api/test_views/test_organizations_view.py +++ b/tests/platform/api/test_views/test_organizations_view.py @@ -9,6 +9,13 @@ def get_url() -> str: return reverse("django_email_learning:api_platform:organizations_view") +def update_url(organization_id: int) -> str: + return reverse( + "django_email_learning:api_platform:single_organization_view", + kwargs={"organization_id": organization_id}, + ) + + @pytest.fixture(autouse=True) def second_organization(db): org = Organization(name="Second Org", description="The second organization") @@ -46,40 +53,32 @@ def test_post_organizations_view_as_superadmin(superadmin_client): assert response.json().get("name") == "New Org" -def test_create_organization_ignore_none_exisiting_logo_file(superadmin_client): - payload = { - "name": "Org with Logo", - "description": "Organization with non-existing logo file", - "logo": "non_existing_logo.png", - } - response = superadmin_client.post( - get_url(), data=payload, content_type="application/json" - ) - assert response.status_code == 201 - assert response.json().get("name") == "Org with Logo" - assert response.json().get("logo") is None +@pytest.fixture +def existing_logo_path(): + with override_settings( + STORAGES={"default": {"BACKEND": "django.core.files.storage.InMemoryStorage"}} + ): + logo_path = "test_logo.png" + with default_storage.open(logo_path, "w") as f: + f.write("dummy image content") + yield logo_path -@override_settings( - STORAGES={"default": {"BACKEND": "django.core.files.storage.InMemoryStorage"}} -) -def test_create_organization_for_existing_logo_file(superadmin_client): +def test_create_organization_for_existing_logo_file( + superadmin_client, existing_logo_path +): # Create a dummy logo file in the default storage - logo_path = "existing_logo.png" - with default_storage.open(logo_path, "w") as f: - f.write("dummy image content") - payload = { "name": "OrgName", "description": "Organization with existing logo file", - "logo": logo_path, + "logo": existing_logo_path, } response = superadmin_client.post( get_url(), data=payload, content_type="application/json" ) assert response.status_code == 201 assert response.json().get("name") == "OrgName" - assert response.json().get("logo").endswith(f"/{logo_path}") + assert response.json().get("logo").endswith(f"/{existing_logo_path}") @pytest.mark.parametrize( @@ -97,3 +96,33 @@ def test_post_organizations_view_as_anonymous(anonymous_client): get_url(), data=payload, content_type="application/json" ) assert response.status_code == 401 + + +def test_update_organizations_view(superadmin_client, existing_logo_path): + organization = Organization.objects.first() + initial_name = organization.name + initial_description = organization.description + initial_logo = organization.logo + payload = { + "name": "Updated Org", + "description": "Updated description", + "logo": existing_logo_path, + } + response = superadmin_client.post( + update_url(organization.id), data=payload, content_type="application/json" + ) + assert response.status_code == 200 + + assert response.json().get("name") == "Updated Org" + assert response.json().get("description") == "Updated description" + assert response.json().get("logo").endswith(f"/{existing_logo_path}") + assert response.json().get("name") != initial_name + assert response.json().get("description") != initial_description + assert response.json().get("logo") != initial_logo + + +@pytest.mark.parametrize("client", ["viewer", "editor"], indirect=True) +def test_edit_organization_requires_platform_admin_or_superadmin(client): + payload = {"name": "Updated Org", "description": "Updated description"} + response = client.post(update_url(1), data=payload, content_type="application/json") + assert response.status_code == 403