Skip to content

Commit 1ae373b

Browse files
authored
Merge pull request #140 from AvaCodeSolutions/feat/167/edit-organization-view
feat: #167 Add edit organization API
2 parents 6c7e328 + 3a3776a commit 1ae373b

6 files changed

Lines changed: 121 additions & 36 deletions

File tree

django_email_learning/models.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from enum import StrEnum
88
from typing import Any
99
from django.conf import settings
10+
from django.core.files.storage import default_storage
1011
from django.urls import reverse
1112
from django.db import models, transaction
1213
from django.core.validators import MaxValueValidator, MinValueValidator
@@ -49,6 +50,19 @@ class Organization(models.Model):
4950
def __str__(self) -> str:
5051
return self.name
5152

53+
def replace_logo(self, file_path: str) -> str:
54+
if default_storage.exists(file_path):
55+
allowed_extensions = [".jpg", ".jpeg", ".png", ".svg"]
56+
if not any(file_path.lower().endswith(ext) for ext in allowed_extensions):
57+
raise ValueError("Logo must be an image file with a valid extension.")
58+
final_path = f"organization_logos/{self.id}/{file_path.split('/')[-1]}"
59+
default_storage.save(final_path, default_storage.open(file_path))
60+
self.logo = final_path
61+
self.save()
62+
return final_path
63+
else:
64+
raise ValueError("Logo file does not exist.")
65+
5266

5367
class OrganizationUser(models.Model):
5468
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="memberships")

django_email_learning/platform/api/serializers.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
)
99
from datetime import datetime
1010
from typing import Optional, Literal, Any, Callable
11-
from django.core.files.storage import default_storage
1211
from django.urls import reverse
1312
from django_email_learning.models import (
1413
DeliveryStatus,
@@ -196,16 +195,22 @@ def to_django_model(self) -> Organization:
196195
organization = Organization(name=self.name, description=self.description)
197196
organization.save()
198197
organization.refresh_from_db()
199-
if self.logo and default_storage.exists(self.logo):
200-
final_path = (
201-
f"organization_logos/{organization.id}/{self.logo.split('/')[-1]}"
202-
)
203-
default_storage.save(final_path, default_storage.open(self.logo))
204-
organization.logo = final_path
198+
if self.logo:
199+
organization.replace_logo(self.logo)
205200

206201
return organization
207202

208203

204+
class UpdateOrganizationRequest(BaseModel):
205+
model_config = ConfigDict(extra="forbid")
206+
name: Optional[str] = Field(None, min_length=1, examples=["AvaCode"])
207+
description: Optional[str] = Field(
208+
None, examples=["A description of the organization."]
209+
)
210+
logo: Optional[str] = Field(None, examples=["/path/to/logo.png"])
211+
remove_logo: Optional[bool] = Field(None, examples=[True])
212+
213+
209214
class UpdateSessionRequest(BaseModel):
210215
active_organization_id: int = Field(examples=[1])
211216

django_email_learning/platform/api/urls.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
from django_email_learning.platform.api.views import (
44
CourseView,
55
EnrollmentView,
6-
FileUploadView,
6+
FileView,
77
ImapConnectionView,
88
OrganizationsView,
9+
SingleOrganizationView,
910
SingleCourseView,
1011
CourseContentView,
1112
ReorderCourseContentView,
@@ -64,11 +65,16 @@
6465
name="enrollment_view",
6566
),
6667
path(
67-
"organizations/<int:organization_id>/file_upload/",
68-
FileUploadView.as_view(),
69-
name="file_upload_view",
68+
"organizations/<int:organization_id>/file/",
69+
FileView.as_view(),
70+
name="file_view",
7071
),
7172
path("organizations/", OrganizationsView.as_view(), name="organizations_view"),
73+
path(
74+
"organizations/<int:organization_id>/",
75+
SingleOrganizationView.as_view(),
76+
name="single_organization_view",
77+
),
7278
path("session", UpdateSessionView.as_view(), name="update_session_view"),
7379
path("", page_not_found, name="root"),
7480
]

django_email_learning/platform/api/views.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -401,15 +401,46 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
401401
return JsonResponse({"error": str(e)}, status=409)
402402

403403

404+
@method_decorator(is_platform_admin(), name="post")
405+
class SingleOrganizationView(View):
406+
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
407+
try:
408+
payload = json.loads(request.body)
409+
serializer = serializers.UpdateOrganizationRequest.model_validate(payload)
410+
organization = Organization.objects.get(id=kwargs["organization_id"])
411+
if serializer.name is not None:
412+
organization.name = serializer.name
413+
if serializer.description is not None:
414+
organization.description = serializer.description
415+
if serializer.logo is not None:
416+
organization.logo = serializer.logo
417+
if serializer.remove_logo:
418+
organization.logo = None
419+
organization.save()
420+
return JsonResponse(
421+
serializers.OrganizationResponse.from_django_model(
422+
organization,
423+
request.build_absolute_uri,
424+
).model_dump(),
425+
status=200,
426+
)
427+
except Organization.DoesNotExist:
428+
return JsonResponse({"error": "Organization not found"}, status=404)
429+
except ValidationError as e:
430+
return JsonResponse({"error": e.json()}, status=400)
431+
except IntegrityError as e:
432+
return JsonResponse({"error": str(e)}, status=409)
433+
434+
404435
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
405-
class FileUploadView(View):
436+
class FileView(View):
406437
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
407438
uploaded_file = request.FILES.get("file")
408439
if not uploaded_file:
409440
return JsonResponse({"error": "No file uploaded"}, status=400)
410441

411442
# check file extension
412-
allowed_extensions = ["png", "jpg", "jpeg", "gif", "bmp", "svg"]
443+
allowed_extensions = ["png", "jpg", "jpeg", "svg"]
413444
file_extension = uploaded_file.name.split(".")[-1].lower()
414445
if file_extension not in allowed_extensions:
415446
return JsonResponse({"error": "Invalid file type"}, status=400)

frontend/platform/organizations/components/OrganizationForm.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ function OrganizationForm({ successCallback, failureCallback, cancelCallback, cr
5454

5555
useEffect(() => {
5656
if (logoFile) {
57-
fetch(`${apiBaseUrl}/organizations/1/file_upload/`, {
57+
fetch(`${apiBaseUrl}/organizations/1/file/`, {
5858
method: 'POST',
5959
headers: {
6060
'X-CSRFToken': getCookie('csrftoken'),

tests/platform/api/test_views/test_organizations_view.py

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ def get_url() -> str:
99
return reverse("django_email_learning:api_platform:organizations_view")
1010

1111

12+
def update_url(organization_id: int) -> str:
13+
return reverse(
14+
"django_email_learning:api_platform:single_organization_view",
15+
kwargs={"organization_id": organization_id},
16+
)
17+
18+
1219
@pytest.fixture(autouse=True)
1320
def second_organization(db):
1421
org = Organization(name="Second Org", description="The second organization")
@@ -46,40 +53,32 @@ def test_post_organizations_view_as_superadmin(superadmin_client):
4653
assert response.json().get("name") == "New Org"
4754

4855

49-
def test_create_organization_ignore_none_exisiting_logo_file(superadmin_client):
50-
payload = {
51-
"name": "Org with Logo",
52-
"description": "Organization with non-existing logo file",
53-
"logo": "non_existing_logo.png",
54-
}
55-
response = superadmin_client.post(
56-
get_url(), data=payload, content_type="application/json"
57-
)
58-
assert response.status_code == 201
59-
assert response.json().get("name") == "Org with Logo"
60-
assert response.json().get("logo") is None
56+
@pytest.fixture
57+
def existing_logo_path():
58+
with override_settings(
59+
STORAGES={"default": {"BACKEND": "django.core.files.storage.InMemoryStorage"}}
60+
):
61+
logo_path = "test_logo.png"
62+
with default_storage.open(logo_path, "w") as f:
63+
f.write("dummy image content")
64+
yield logo_path
6165

6266

63-
@override_settings(
64-
STORAGES={"default": {"BACKEND": "django.core.files.storage.InMemoryStorage"}}
65-
)
66-
def test_create_organization_for_existing_logo_file(superadmin_client):
67+
def test_create_organization_for_existing_logo_file(
68+
superadmin_client, existing_logo_path
69+
):
6770
# Create a dummy logo file in the default storage
68-
logo_path = "existing_logo.png"
69-
with default_storage.open(logo_path, "w") as f:
70-
f.write("dummy image content")
71-
7271
payload = {
7372
"name": "OrgName",
7473
"description": "Organization with existing logo file",
75-
"logo": logo_path,
74+
"logo": existing_logo_path,
7675
}
7776
response = superadmin_client.post(
7877
get_url(), data=payload, content_type="application/json"
7978
)
8079
assert response.status_code == 201
8180
assert response.json().get("name") == "OrgName"
82-
assert response.json().get("logo").endswith(f"/{logo_path}")
81+
assert response.json().get("logo").endswith(f"/{existing_logo_path}")
8382

8483

8584
@pytest.mark.parametrize(
@@ -97,3 +96,33 @@ def test_post_organizations_view_as_anonymous(anonymous_client):
9796
get_url(), data=payload, content_type="application/json"
9897
)
9998
assert response.status_code == 401
99+
100+
101+
def test_update_organizations_view(superadmin_client, existing_logo_path):
102+
organization = Organization.objects.first()
103+
initial_name = organization.name
104+
initial_description = organization.description
105+
initial_logo = organization.logo
106+
payload = {
107+
"name": "Updated Org",
108+
"description": "Updated description",
109+
"logo": existing_logo_path,
110+
}
111+
response = superadmin_client.post(
112+
update_url(organization.id), data=payload, content_type="application/json"
113+
)
114+
assert response.status_code == 200
115+
116+
assert response.json().get("name") == "Updated Org"
117+
assert response.json().get("description") == "Updated description"
118+
assert response.json().get("logo").endswith(f"/{existing_logo_path}")
119+
assert response.json().get("name") != initial_name
120+
assert response.json().get("description") != initial_description
121+
assert response.json().get("logo") != initial_logo
122+
123+
124+
@pytest.mark.parametrize("client", ["viewer", "editor"], indirect=True)
125+
def test_edit_organization_requires_platform_admin_or_superadmin(client):
126+
payload = {"name": "Updated Org", "description": "Updated description"}
127+
response = client.post(update_url(1), data=payload, content_type="application/json")
128+
assert response.status_code == 403

0 commit comments

Comments
 (0)