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() { > - + 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'} /> - setWaitingPeriodUnit(e.target.value)} name="waiting_period_unit" sx={{ width: '150px' }} disabled={userRole === 'viewer'}> Days Hours - + } ); 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}) => { )} - + } {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%' }} /> - + setTitle(e.target.value)} sx={{ mb: 2, width: '100%' }} disabled={userRole === 'viewer'} /> + {userRole !== 'viewer' && } { 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' }} disabled={userRole === 'viewer'}> Days Hours - + } ); 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() { > - + 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