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
14 changes: 14 additions & 0 deletions django_email_learning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
19 changes: 12 additions & 7 deletions django_email_learning/platform/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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])

Expand Down
14 changes: 10 additions & 4 deletions django_email_learning/platform/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
from django_email_learning.platform.api.views import (
CourseView,
EnrollmentView,
FileUploadView,
FileView,
ImapConnectionView,
OrganizationsView,
SingleOrganizationView,
SingleCourseView,
CourseContentView,
ReorderCourseContentView,
Expand Down Expand Up @@ -64,11 +65,16 @@
name="enrollment_view",
),
path(
"organizations/<int:organization_id>/file_upload/",
FileUploadView.as_view(),
name="file_upload_view",
"organizations/<int:organization_id>/file/",
FileView.as_view(),
name="file_view",
),
path("organizations/", OrganizationsView.as_view(), name="organizations_view"),
path(
"organizations/<int:organization_id>/",
SingleOrganizationView.as_view(),
name="single_organization_view",
),
path("session", UpdateSessionView.as_view(), name="update_session_view"),
path("", page_not_found, name="root"),
]
35 changes: 33 additions & 2 deletions django_email_learning/platform/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
73 changes: 51 additions & 22 deletions tests/platform/api/test_views/test_organizations_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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(
Expand All @@ -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