diff --git a/django_email_learning/admin.py b/django_email_learning/admin.py
index f6e026b..88f9c07 100644
--- a/django_email_learning/admin.py
+++ b/django_email_learning/admin.py
@@ -10,6 +10,7 @@
Answer,
CourseContent,
Organization,
+ OrganizationUser,
BlockedEmail,
)
@@ -91,3 +92,4 @@ def get_content_title(self, obj: CourseContent) -> str | None:
admin.site.register(Answer)
admin.site.register(Organization)
admin.site.register(BlockedEmail)
+admin.site.register(OrganizationUser)
diff --git a/django_email_learning/ports/__init__.py b/django_email_learning/ports/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/django_email_learning/ports/email_sender_protocol.py b/django_email_learning/ports/email_sender_protocol.py
new file mode 100644
index 0000000..0b677d6
--- /dev/null
+++ b/django_email_learning/ports/email_sender_protocol.py
@@ -0,0 +1,7 @@
+from typing import Protocol
+from django.core.mail import EmailMultiAlternatives
+
+
+class EmailSenderProtocol(Protocol):
+ def send_email(self, email: EmailMultiAlternatives) -> None:
+ ...
diff --git a/django_email_learning/services/__init__.py b/django_email_learning/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/django_email_learning/services/deafults/__init__.py b/django_email_learning/services/deafults/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/django_email_learning/services/deafults/email_sender.py b/django_email_learning/services/deafults/email_sender.py
new file mode 100644
index 0000000..5ff215c
--- /dev/null
+++ b/django_email_learning/services/deafults/email_sender.py
@@ -0,0 +1,33 @@
+import logging
+from django_email_learning.ports.email_sender_protocol import EmailSenderProtocol
+from django.core.mail import EmailMultiAlternatives
+
+logger = logging.getLogger(__name__)
+
+
+class DjangoEmailSender(EmailSenderProtocol):
+ def _mask_email(self, email_address: str) -> str:
+ """Mask email address for logging privacy."""
+ try:
+ username, domain = email_address.split("@")
+ masked_username = username[0] + "***"
+ return f"{masked_username}@{domain}"
+ except ValueError:
+ return "***@***"
+
+ def _mask_recipients(self, recipients: list[str]) -> str:
+ """Mask all recipient email addresses for logging."""
+ if not recipients:
+ return "no recipients"
+ masked = [self._mask_email(recipient) for recipient in recipients]
+ return ", ".join(masked)
+
+ def send_email(self, email: EmailMultiAlternatives) -> None:
+ masked_recipients = self._mask_recipients(email.to)
+ try:
+ logger.info(f"Sending email to {masked_recipients}")
+ email.send()
+ logger.info(f"Email sent successfully to {masked_recipients}")
+ except Exception as e:
+ logger.error(f"Failed to send email to {masked_recipients}: {str(e)}")
+ raise
diff --git a/frontend/course/Course.jsx b/frontend/course/Course.jsx
index fdd1bd6..95aa0b9 100644
--- a/frontend/course/Course.jsx
+++ b/frontend/course/Course.jsx
@@ -21,6 +21,7 @@ function Course() {
const [lessonCache, setLessonCache] = useState("")
const [contentLoaded, setContentLoaded] = useState(false)
+ const userRole = localStorage.getItem('userRole');
const apiBaseUrl = localStorage.getItem('apiBaseUrl');
const organizationId = localStorage.getItem('activeOrganizationId');
@@ -120,7 +121,7 @@ function Course() {
>
- } sx={{ marginBottom: 2 }} onClick={() => {
+ {userRole !== 'viewer' && <>} sx={{ marginBottom: 2 }} onClick={() => {
setDialogContent( setDialogOpen(false)}
successCallback={resetDialog}
courseId={course_id} />);
- setDialogOpen(true);}}>Add a Quiz
+ setDialogOpen(true);}}>Add a Quiz> }
tableEventHandler(event)} />
diff --git a/frontend/course/components/ContentTable.jsx b/frontend/course/components/ContentTable.jsx
index 2957b81..9ae0539 100644
--- a/frontend/course/components/ContentTable.jsx
+++ b/frontend/course/components/ContentTable.jsx
@@ -9,6 +9,8 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => {
const apiBaseUrl = localStorage.getItem('apiBaseUrl');
const organizationId = localStorage.getItem('activeOrganizationId');
+ const userRole = localStorage.getItem('userRole');
+
const formatPeriod = (period) => {
if (!period) {
return "";
@@ -97,7 +99,7 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => {
Waiting time
type
Published
- Actions
+ {userRole !== 'viewer' && Actions}
@@ -108,12 +110,12 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => {
color='primary.dark' sx={{ cursor: 'pointer'}}>{content.title}
{formatPeriod(content.waiting_period)}
{content.type}
- TogglePublishContent(content.id, !content.is_published)} />
-
+ TogglePublishContent(content.id, !content.is_published)} disabled={userRole == 'viewer'} />
+ {userRole !== 'viewer' &&
deleteContent(content.id)}>
-
+ }
))}
diff --git a/frontend/course/components/LessonForm.jsx b/frontend/course/components/LessonForm.jsx
index d6dc054..8a58c29 100644
--- a/frontend/course/components/LessonForm.jsx
+++ b/frontend/course/components/LessonForm.jsx
@@ -13,6 +13,7 @@ function LessonForm({ header, initialTitle, initialContent, onContentChange, can
const [contentHelperText, setContentHelperText] = useState("");
const [errorMessage, setErrorMessage] = useState("");
+ const userRole = localStorage.getItem('userRole');
const apiBaseUrl = localStorage.getItem('apiBaseUrl');
const orgId = localStorage.getItem('activeOrganizationId');
@@ -126,9 +127,9 @@ function LessonForm({ header, initialTitle, initialContent, onContentChange, can
{errorMessage}
)}
- setTitle(e.target.value)} helperText={titleHelperText}/>
+ setTitle(e.target.value)} helperText={titleHelperText} disabled={userRole === 'viewer'} />
-
+
{contentHelperText}
@@ -144,19 +145,20 @@ function LessonForm({ header, initialTitle, initialContent, onContentChange, can
onChange={(e) => setWaitingPeriod(e.target.value)}
sx={{ width: '200px', mr: 2 }}
inputProps={{ min: 1 }}
+ disabled={userRole === 'viewer'}
/>
-
);
diff --git a/frontend/course/components/QuestionForm.jsx b/frontend/course/components/QuestionForm.jsx
index 08d169e..59359ca 100644
--- a/frontend/course/components/QuestionForm.jsx
+++ b/frontend/course/components/QuestionForm.jsx
@@ -14,8 +14,10 @@ const QuestionForm = ({question, index, eventHandler}) => {
const [addingOption, setAddingOption] = useState(false);
const optionInputRef = useRef(null);
+ const userRole = localStorage.getItem('userRole');
+
const editQuestion = () => {
- if (editMode && questionText.trim() === '') {
+ if (editMode && questionText.trim() === '' || userRole === 'viewer') {
return;
}
triggerUpdateEvent();
@@ -61,7 +63,7 @@ const QuestionForm = ({question, index, eventHandler}) => {
-
+ {userRole !== 'viewer' && }
{!editMode ? (
{index + 1}. {questionText}
) : (
@@ -77,12 +79,12 @@ const QuestionForm = ({question, index, eventHandler}) => {
)}
- setAddingOption(true)} >
+ {userRole !== 'viewer' && <> setAddingOption(true)} >
Add Option
Delete
-
+ >}
{addingOption && (<>
@@ -125,7 +127,7 @@ const QuestionForm = ({question, index, eventHandler}) => {
Options
Correct Answer
- Actions
+ {userRole !== 'viewer' && Actions}
@@ -146,8 +148,8 @@ const QuestionForm = ({question, index, eventHandler}) => {
}}
/>
)}
- updateOption(idx, e.target.checked)} checked={option.isCorrect} />
-
+ updateOption(idx, e.target.checked)} checked={option.isCorrect} disabled={userRole === 'viewer'} />
+ {userRole !== 'viewer' &&
{
setOptions(options.map((opt, i) => i === idx ? { ...opt, editMode: !opt.editMode } : opt));
}} />
@@ -155,7 +157,7 @@ const QuestionForm = ({question, index, eventHandler}) => {
const updatedOptions = options.filter((_, i) => i !== idx);
setOptions(updatedOptions);
}} />
-
+ }
))}
diff --git a/frontend/course/components/QuizForm.jsx b/frontend/course/components/QuizForm.jsx
index 06363e9..cf82cf2 100644
--- a/frontend/course/components/QuizForm.jsx
+++ b/frontend/course/components/QuizForm.jsx
@@ -19,7 +19,7 @@ const QuizForm = ({cancelCallback, successCallback, courseId, quizId, contentId,
const dialogRef = useRef(null);
const apiBaseUrl = localStorage.getItem('apiBaseUrl');
const organizationId = localStorage.getItem('activeOrganizationId');
-
+ const userRole = localStorage.getItem('userRole');
const addQuiz = () => {
if (!validateQuiz()) {
@@ -201,9 +201,9 @@ const QuizForm = ({cancelCallback, successCallback, courseId, quizId, contentId,
}} tabIndex={0} focusable="true">
{ quizId ? "Update Quiz" : "New Quiz" }
{errorMessage && {errorMessage}}
- setTitle(e.target.value)} sx={{ mb: 2, width: '100%' }} />
- setShowQuestionField(true)}>
- Add Question
+ setTitle(e.target.value)} sx={{ mb: 2, width: '100%' }} disabled={userRole === 'viewer'} />
+ {userRole !== 'viewer' && setShowQuestionField(true)}>
+ Add Question}
{ showQuestionField && (
@@ -240,6 +240,7 @@ const QuizForm = ({cancelCallback, successCallback, courseId, quizId, contentId,
onChange={(e) => setRequiredScore(e.target.value)}
sx={{ width: '200px', mr: 2 }}
inputProps={{ min: 0, max: 100 }}
+ disabled={userRole === 'viewer'}
>
@@ -254,19 +255,20 @@ const QuizForm = ({cancelCallback, successCallback, courseId, quizId, contentId,
onChange={(e) => setWaitingPeriod(e.target.value)}
sx={{ width: '200px', mr: 2 }}
inputProps={{ min: 1 }}
+ disabled={userRole === 'viewer'}
/>
- setWaitingPeriodUnit(e.target.value)} name="waiting_period_unit" sx={{ width: '150px' }}>
+ setWaitingPeriodUnit(e.target.value)} name="waiting_period_unit" sx={{ width: '150px' }} disabled={userRole === 'viewer'}>
- Cancel
+ Back
- {if(!quizId) { addQuiz(); } else { updateQuiz(); }}}>
+ {userRole !== 'viewer' && {if(!quizId) { addQuiz(); } else { updateQuiz(); }}}>
Save Quiz
-
+ }
);
diff --git a/frontend/courses/Courses.jsx b/frontend/courses/Courses.jsx
index 53e731f..297a395 100644
--- a/frontend/courses/Courses.jsx
+++ b/frontend/courses/Courses.jsx
@@ -20,6 +20,7 @@ function Courses() {
const [courses, setCourses] = useState([])
const [organizationId, setOrganizationId] = useState(null);
const [queryParameters, setQueryParameters] = useState("");
+ const userRole = localStorage.getItem('userRole');
const apiBaseUrl = localStorage.getItem('apiBaseUrl');
const platformBaseUrl = localStorage.getItem('platformBaseUrl');
@@ -106,7 +107,7 @@ function Courses() {
>
- } sx={{ marginBottom: 2 }} onClick={() => {
+ {userRole !== 'viewer' && } sx={{ marginBottom: 2 }} onClick={() => {
setDialogContent();
- setDialogOpen(true);}}>Add a Course
+ setDialogOpen(true);}}>Add a Course}
@@ -122,7 +123,7 @@ function Courses() {
Title
Slug
Enabled
- Actions
+ {userRole !== 'viewer' && Actions}
@@ -140,9 +141,10 @@ function Courses() {
checked={course.enabled}
onChange={showEnableCoursePopup(course.id, course.enabled ? 'disable' : 'enable', course.title)}
slotProps={{ input: { 'aria-label': course.enabled ? 'Disable Course' : 'Enable Course' } }}
+ disabled={userRole === 'viewer'}
/>
-
+ {userRole !== 'viewer' &&
{
showEditCourseDialog(course);}}>
{
@@ -152,7 +154,7 @@ function Courses() {
}} />);
setDialogOpen(true);
}}>
-
+ }
))}
diff --git a/frontend/src/components/ContentEditor.jsx b/frontend/src/components/ContentEditor.jsx
index 132414b..6cc44f8 100644
--- a/frontend/src/components/ContentEditor.jsx
+++ b/frontend/src/components/ContentEditor.jsx
@@ -20,7 +20,7 @@ import FormatBoldIcon from '@mui/icons-material/FormatBold';
import ImageIcon from '@mui/icons-material/Image';
-function ContentEditor({ initialContent, contentUpdateCallback }) {
+function ContentEditor({ initialContent, contentUpdateCallback, disabled = false }) {
const editor = useEditor({
extensions: [
Document,
@@ -40,6 +40,7 @@ function ContentEditor({ initialContent, contentUpdateCallback }) {
}),
Dropcursor,],
content: initialContent,
+ editable: !disabled,
autofocus: true,
onUpdate: ({ editor }) => {
contentUpdateCallback(editor.getHTML());
@@ -54,7 +55,7 @@ function ContentEditor({ initialContent, contentUpdateCallback }) {
{/* Material UI Toolbar */}
-
-
+ }
{/* TipTap Editor wrapped in Material UI Box */}
DjangoEmailSender:
+ return DjangoEmailSender()
+
+
+@pytest.fixture
+def email_multi_alternatives() -> EmailMultiAlternatives:
+ email = Mock(spec=EmailMultiAlternatives)
+ email.to = ["recipient@example.com"]
+ return email
+
+
+def test_email_sender_logs_success_with_masked_recipients(
+ email_sender: DjangoEmailSender,
+ email_multi_alternatives: EmailMultiAlternatives,
+ caplog,
+):
+ with caplog.at_level("INFO"):
+ email_sender.send_email(email_multi_alternatives)
+ assert "Sending email to r***@example.com" in caplog.text
+
+
+def test_email_sender_logs_failure_with_masked_recipients(
+ email_sender: DjangoEmailSender,
+ email_multi_alternatives: EmailMultiAlternatives,
+ caplog,
+):
+ email_multi_alternatives.send.side_effect = Exception("SMTP error")
+
+ with caplog.at_level("ERROR"):
+ with pytest.raises(Exception):
+ email_sender.send_email(email_multi_alternatives)
+
+ assert "Failed to send email to r***@example.com" in caplog.text