Skip to content

Commit c69b080

Browse files
committed
feat: #166 File upload for organizations Logo and other use cases
1 parent e327b35 commit c69b080

7 files changed

Lines changed: 265 additions & 18 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,6 @@ coverage.xml
3333
.DS_Store
3434
.idea/
3535
.vscode/
36+
37+
# Media files
38+
media/

django_email_learning/platform/api/serializers.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
model_validator,
88
)
99
from datetime import datetime
10-
from typing import Optional, Literal, Any
10+
from typing import Optional, Literal, Any, Callable
1111
from django.core.files.storage import default_storage
12+
from django.urls import reverse
1213
from django_email_learning.models import (
1314
DeliveryStatus,
1415
Organization,
@@ -157,21 +158,50 @@ class ImapConnectionResponse(BaseModel):
157158
class OrganizationResponse(BaseModel):
158159
id: int
159160
name: str
161+
logo: Optional[str] = None
162+
description: Optional[str] = None
163+
public_url: str
160164

161165
model_config = ConfigDict(from_attributes=True)
162166

167+
@staticmethod
168+
def from_django_model(
169+
organization: Organization, abs_url_builder: Callable
170+
) -> "OrganizationResponse":
171+
url = reverse(
172+
"django_email_learning:public:organization_view",
173+
kwargs={"organization_id": organization.id},
174+
)
175+
return OrganizationResponse.model_validate(
176+
{
177+
"id": organization.id,
178+
"name": organization.name,
179+
"logo": abs_url_builder(organization.logo.url)
180+
if organization.logo
181+
else None,
182+
"description": organization.description,
183+
"public_url": abs_url_builder(url),
184+
}
185+
)
186+
163187

164188
class CreateOrganizationRequest(BaseModel):
165189
name: str = Field(min_length=1, examples=["AvaCode"])
166190
description: Optional[str] = Field(
167191
None, examples=["A description of the organization."]
168192
)
169-
logo_path: Optional[str] = Field(None, examples=["/path/to/logo.png"])
193+
logo: Optional[str] = Field(None, examples=["/path/to/logo.png"])
170194

171195
def to_django_model(self) -> Organization:
172196
organization = Organization(name=self.name, description=self.description)
173-
if self.logo_path and default_storage.exists(self.logo_path):
174-
organization.logo = self.logo_path
197+
organization.save()
198+
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
175205

176206
return organization
177207

django_email_learning/platform/api/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django_email_learning.platform.api.views import (
44
CourseView,
55
EnrollmentView,
6+
FileUploadView,
67
ImapConnectionView,
78
OrganizationsView,
89
SingleCourseView,
@@ -62,6 +63,11 @@
6263
EnrollmentView.as_view(),
6364
name="enrollment_view",
6465
),
66+
path(
67+
"organizations/<int:organization_id>/file_upload/",
68+
FileUploadView.as_view(),
69+
name="file_upload_view",
70+
),
6571
path("organizations/", OrganizationsView.as_view(), name="organizations_view"),
6672
path("session", UpdateSessionView.as_view(), name="update_session_view"),
6773
path("", page_not_found, name="root"),

django_email_learning/platform/api/views.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
from django.http import JsonResponse
66
from django.core.exceptions import ValidationError as DjangoValidationError
77
from django.db import models, transaction
8-
8+
from django.core.files.storage import default_storage
9+
from django.utils import timezone
910
from pydantic import ValidationError
1011

1112
from django_email_learning.platform.api import serializers
@@ -370,7 +371,9 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
370371
response_list = []
371372
for org in organizations:
372373
response_list.append(
373-
serializers.OrganizationResponse.model_validate(org).model_dump()
374+
serializers.OrganizationResponse.from_django_model(
375+
org, request.build_absolute_uri
376+
).model_dump()
374377
)
375378
return JsonResponse({"organizations": response_list}, status=200)
376379

@@ -386,8 +389,9 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
386389
)
387390
org_user.save()
388391
return JsonResponse(
389-
serializers.OrganizationResponse.model_validate(
390-
organization
392+
serializers.OrganizationResponse.from_django_model(
393+
organization,
394+
request.build_absolute_uri,
391395
).model_dump(),
392396
status=201,
393397
)
@@ -397,6 +401,29 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
397401
return JsonResponse({"error": str(e)}, status=409)
398402

399403

404+
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
405+
class FileUploadView(View):
406+
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
407+
uploaded_file = request.FILES.get("file")
408+
if not uploaded_file:
409+
return JsonResponse({"error": "No file uploaded"}, status=400)
410+
411+
# check file extension
412+
allowed_extensions = ["png", "jpg", "jpeg", "gif", "bmp", "svg"]
413+
file_extension = uploaded_file.name.split(".")[-1].lower()
414+
if file_extension not in allowed_extensions:
415+
return JsonResponse({"error": "Invalid file type"}, status=400)
416+
417+
date_prefix = timezone.now().strftime("%Y%m%d")
418+
419+
file_path = default_storage.save(
420+
f"uploads/{date_prefix}/{kwargs['organization_id']}/{uploaded_file.name}",
421+
uploaded_file,
422+
)
423+
file_url = default_storage.url(file_path)
424+
return JsonResponse({"file_url": file_url, "file_path": file_path}, status=201)
425+
426+
400427
@method_decorator(is_an_organization_member(), name="post")
401428
class UpdateSessionView(View):
402429
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]

frontend/platform/organizations/Organizations.jsx

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,41 @@
11
import Base from "../../src/components/Base";
2-
import { Box, Button, Dialog, Grid } from "@mui/material";
2+
import { Box, Button, Dialog, Grid, IconButton, TableContainer, Table, TableHead, TableRow,TableBody, TableCell } from "@mui/material";
33
import AddIcon from '@mui/icons-material/Add';
4-
import { useState } from "react";
4+
import PublicIcon from '@mui/icons-material/Public';
5+
import { useState, useEffect, use } from "react";
6+
import { getCookie } from "../../src/utils";
57
import render from "../../src/render";
68
import OrganizationForm from "./components/OrganizationForm";
79

810
function Organizations() {
911
const [dialogOpen, setDialogOpen] = useState(false);
1012
const [dialogContent, setDialogContent] = useState(null);
13+
const [organizations, setOrganizations] = useState([]);
14+
const [tableUpdates, setTableUpdates] = useState([]);
15+
16+
useEffect(() => {
17+
fetch(`${apiBaseUrl}/organizations/`, {
18+
method: 'GET',
19+
headers: {
20+
'Content-Type': 'application/json',
21+
'X-CSRFToken': getCookie('csrftoken'),
22+
},
23+
})
24+
.then(response => response.json())
25+
.then(data => {
26+
setOrganizations(data.organizations);
27+
})
28+
.catch(error => {
29+
console.error('Error fetching organizations:', error);
30+
});
31+
}, [tableUpdates]);
32+
33+
const apiBaseUrl = localStorage.getItem('apiBaseUrl');
1134

1235
const handleOrganizationCreated = (data) => {
1336
console.log('Organization created successfully:', data);
1437
setDialogOpen(false);
38+
setTableUpdates(prev => [...prev, data]);
1539
};
1640

1741
const handleOrganizationCreationFailed = (error) => {
@@ -22,7 +46,7 @@ function Organizations() {
2246
<Base breadCrumbList={[{label: 'Organizations', href: '#'}]} showOrganizationSwitcher={false}>
2347
<Grid size={12} py={2} pl={2}>
2448
<Box p={2} sx={{ border: '1px solid', borderColor: 'grey.300', borderRadius: 1, minHeight: 300 }}>
25-
<Button variant="outlined" startIcon={<AddIcon />} sx={{ marginBottom: 2 }} onClick={() => {
49+
<Button variant="contained" startIcon={<AddIcon />} sx={{ marginBottom: 2 }} onClick={() => {
2650
setDialogContent(<OrganizationForm
2751
successCallback={handleOrganizationCreated}
2852
failureCallback={handleOrganizationCreationFailed}
@@ -31,8 +55,34 @@ function Organizations() {
3155
/>);
3256
setDialogOpen(true);
3357
}}>Add an Organization</Button>
58+
59+
{ organizations.length > 0 && (<TableContainer sx={{ maxHeight: 440, border: '1px solid', borderColor: 'grey.300', borderRadius: 1 }}>
60+
<Table>
61+
<TableHead>
62+
<TableRow>
63+
<TableCell>Name</TableCell>
64+
<TableCell>Public URL</TableCell>
65+
<TableCell>Actions</TableCell>
66+
</TableRow>
67+
</TableHead>
68+
<TableBody>
69+
{ organizations.map((org) => (
70+
<TableRow key={org.id}>
71+
<TableCell>{org.name}</TableCell>
72+
<TableCell><a href={org.public_url}><IconButton><PublicIcon fontSize="small"/></IconButton></a></TableCell>
73+
<TableCell>Actions</TableCell>
74+
</TableRow>
75+
))}
76+
</TableBody>
77+
</Table>
78+
</TableContainer>)}
79+
3480
</Box>
81+
3582
</Grid>
83+
84+
85+
3686
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="sm">
3787
{dialogContent}
3888
</Dialog>

frontend/platform/organizations/components/OrganizationForm.jsx

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,120 @@
1-
import { Box, Button, DialogActions, TextField } from "@mui/material";
1+
import { styled } from '@mui/material/styles';
2+
import { Box, Button, DialogActions } from "@mui/material";
23
import RequiredTextField from "../../../src/components/RequiredTextField.jsx";
3-
import { useState } from "react";
4+
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
5+
import { useState, useEffect, use } from "react";
6+
import { getCookie } from '../../../src/utils.js';
47

58
function OrganizationForm({ successCallback, failureCallback, cancelCallback, createMode }) {
69
const [name, setName] = useState("");
710
const [description, setDescription] = useState("");
811
const [nameHelperText, setNameHelperText] = useState("");
912
const [descriptionHelperText, setDescriptionHelperText] = useState("");
13+
const [logoFile, setLogoFile] = useState(null);
14+
const [logoUrl, setLogoUrl] = useState(null);
15+
const [logoServerPath, setLogoServerPath] = useState(null);
1016

1117
const apiBaseUrl = localStorage.getItem('apiBaseUrl');
1218

19+
const removeLogo = () => {
20+
setLogoUrl(null);
21+
setLogoServerPath(null);
22+
setLogoFile(null);
23+
}
24+
1325
const handleCreate = () => (event) => {
1426
event.preventDefault();
15-
// Implement organization creation logic here
16-
// On success, call successCallback with organization data
17-
// On failure, call failureCallback with error
18-
successCallback({ id: 1, name: "New Organization" });
27+
fetch(`${apiBaseUrl}/organizations/`, {
28+
method: 'POST',
29+
headers: {
30+
'Content-Type': 'application/json',
31+
'X-CSRFToken': getCookie('csrftoken'),
32+
},
33+
body: JSON.stringify({
34+
name: name,
35+
description: description,
36+
logo: logoServerPath,
37+
}),
38+
})
39+
.then(response => {
40+
if (!response.ok) {
41+
return response.json().then(data => {
42+
throw data;
43+
});
44+
}
45+
return response.json();
46+
})
47+
.then(data => {
48+
successCallback(data);
49+
})
50+
.catch(error => {
51+
failureCallback(error);
52+
});
1953
}
54+
55+
useEffect(() => {
56+
if (logoFile) {
57+
fetch(`${apiBaseUrl}/organizations/1/file_upload/`, {
58+
method: 'POST',
59+
headers: {
60+
'X-CSRFToken': getCookie('csrftoken'),
61+
},
62+
body: (() => {
63+
const formData = new FormData();
64+
formData.append('file', logoFile);
65+
return formData;
66+
})(),
67+
})
68+
.then(response => {
69+
if (!response.ok) {
70+
throw new Error('File upload failed');
71+
}
72+
return response.json();
73+
})
74+
.then(data => {
75+
console.log('File uploaded successfully:', data);
76+
setLogoUrl(data.file_url);
77+
setLogoServerPath(data.file_path);
78+
console.log('File path set to:', logoServerPath);
79+
})
80+
.catch(error => {
81+
console.error('Error uploading file:', error);
82+
});
83+
}
84+
}, [logoFile]);
85+
86+
const VisuallyHiddenInput = styled('input')({
87+
clip: 'rect(0 0 0 0)',
88+
clipPath: 'inset(50%)',
89+
height: 1,
90+
overflow: 'hidden',
91+
position: 'absolute',
92+
bottom: 0,
93+
left: 0,
94+
whiteSpace: 'nowrap',
95+
width: 1,
96+
});
97+
2098
return (
2199
<Box p={2}>
22100
<RequiredTextField label="Name" helperText={nameHelperText} fullWidth margin="normal" value={name} onChange={(e) => setName(e.target.value)} />
23101
<RequiredTextField label="Description" helperText={descriptionHelperText} fullWidth margin="normal" multiline rows={4} value={description} onChange={(e) => setDescription(e.target.value)} />
24-
Logo: File upload component
102+
{ !logoUrl ? <Button
103+
component="label"
104+
role={undefined}
105+
variant="contained"
106+
tabIndex={-1}
107+
startIcon={<CloudUploadIcon />}
108+
>
109+
Logo
110+
<VisuallyHiddenInput
111+
type="file"
112+
onChange={(event) => setLogoFile(event.target.files[0])}
113+
/>
114+
</Button>
115+
: (<><img src={logoUrl} alt="Organization Logo" style={{ marginTop: '10px', maxHeight: '100px' }} /><br />
116+
<Button variant="text" color="secondary" onClick={removeLogo}>Remove Logo</Button></>
117+
)}
25118
<DialogActions>
26119
<Button onClick={cancelCallback}>Cancel</Button>
27120
<Button type="submit" color="primary" onClick={handleCreate()}>

0 commit comments

Comments
 (0)