diff --git a/BackEnd/administration/templates/administration/admin_message_template.html b/BackEnd/administration/templates/administration/admin_message_template.html new file mode 100644 index 00000000..9235b2d4 --- /dev/null +++ b/BackEnd/administration/templates/administration/admin_message_template.html @@ -0,0 +1,47 @@ +<!DOCTYPE html> +<html lang="uk"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Нове повідомлення</title> + <style> + body { + font-family: Arial, sans-serif; + color: black; + } + .email-container { + padding: 20px; + border: 1px solid #ddd; + border-radius: 5px; + background-color: #f9f9f9; + } + .email-footer { + margin-top: 20px; + font-size: 12px; + color: gray; + } + img { + max-width: 150px; + } + .email-body { + margin-bottom: 20px; + } + </style> +</head> +<body> + <div class="email-container"> + <img src="{{ logo_url }}" alt="CraftMerge Logo" /> + <div class="email-body"> + <p>Доброго дня, {{ user_name }}!</p> + <p>Ви отримали нове повідомлення:</p> + <p><b>Категорія:</b> {{ category }}</p> + <p><b>Повідомлення:</b></p> + <p>{{ message }}</p> + </div> + <div class="email-footer"> + <p>З повагою,</p> + <p>Команда CraftMerge</p> + </div> + </div> +</body> +</html> diff --git a/BackEnd/administration/tests/test_send_message.py b/BackEnd/administration/tests/test_send_message.py new file mode 100644 index 00000000..eb3784b9 --- /dev/null +++ b/BackEnd/administration/tests/test_send_message.py @@ -0,0 +1,109 @@ +from django.core import mail +from django.conf import settings +from rest_framework import status +from rest_framework.test import APITestCase +from authentication.factories import UserFactory +from utils.administration.send_email_notification import send_email_to_user + + +class TestSendMessageView(APITestCase): + def setUp(self): + self.admin = UserFactory(is_staff=True, is_active=True) + self.user = UserFactory(is_active=True) + self.client.force_authenticate(self.admin) + self.url = f"/api/admin/users/{self.user.id}/send_message/" + + def test_send_message_success(self): + data = { + "email": self.user.email, + "category": "Інше", + "message": "Valid message for testing.", + } + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_send_message_invalid_data(self): + data = { + "email": self.user.email, + "category": "Iнше", + "message": "Short", + } + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_send_message_unauthorized(self): + self.client.logout() + data = { + "email": self.user.email, + "category": "Інше", + "message": "Valid message for testing.", + } + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_send_message_user_not_found(self): + url = "/api/admin/users/9999/send_message/" + data = { + "email": "nonexistent@test.com", + "category": "Інше", + "message": "Valid message for testing.", + } + response = self.client.post(url, data) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class TestSendEmailFunctionality(APITestCase): + def setUp(self): + self.user = UserFactory( + name="Test", surname="User", email="test_user@example.com" + ) + + def send_email(self, category, message_content, email=None): + email = email if email else self.user.email + return send_email_to_user( + user=self.user, + category=category, + message_content=message_content, + email=email, + ) + + def test_send_email_success(self): + self.send_email( + category="Інше", + message_content="This is a test message.", + email="test_user@example.com", + ) + + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.subject, "Адміністратор CraftMerge - Інше") + self.assertIn("This is a test message.", email.body) + self.assertEqual(email.to, ["test_user@example.com"]) + self.assertEqual(email.from_email, settings.EMAIL_HOST_USER) + + def test_send_email_empty_message(self): + with self.assertRaises(ValueError) as e: + self.send_email( + category="Інше", + message_content="", + email="test_user@example.com", + ) + self.assertEqual(str(e.exception), "Message content cannot be empty.") + + def test_send_email_invalid_email(self): + with self.assertRaises(ValueError) as e: + self.send_email( + category="Інше", + message_content="Test message", + email="invalid_email", + ) + self.assertEqual(str(e.exception), "Invalid email address.") + + def test_send_email_missing_category(self): + with self.assertRaises(ValueError) as e: + self.send_email( + category="", + message_content="Test message", + email="test_user@example.com", + ) + self.assertEqual(str(e.exception), "Category is required.") diff --git a/BackEnd/administration/urls.py b/BackEnd/administration/urls.py index d860830c..b71b9695 100644 --- a/BackEnd/administration/urls.py +++ b/BackEnd/administration/urls.py @@ -10,6 +10,7 @@ ModerationEmailView, FeedbackView, CreateAdminUserView, + SendMessageView, ) app_name = "administration" @@ -28,4 +29,9 @@ path("contacts/", ContactsView.as_view(), name="contacts"), path("feedback/", FeedbackView.as_view(), name="feedback"), path("admin_create/", CreateAdminUserView.as_view(), name="admin-create"), + path( + "users/<pk>/send_message/", + SendMessageView.as_view(), + name="send-message", + ), ] diff --git a/BackEnd/administration/views.py b/BackEnd/administration/views.py index 7f137a5b..52db67f0 100644 --- a/BackEnd/administration/views.py +++ b/BackEnd/administration/views.py @@ -5,7 +5,6 @@ OpenApiExample, OpenApiResponse, ) - from rest_framework.generics import ( ListAPIView, RetrieveUpdateDestroyAPIView, @@ -30,6 +29,7 @@ from .permissions import IsStaffUser, IsStaffUserOrReadOnly, IsSuperUser from .serializers import FeedbackSerializer from utils.administration.send_email_feedback import send_email_feedback +from utils.administration.send_email_notification import send_email_to_user from django_filters.rest_framework import DjangoFilterBackend from .filters import UsersFilter @@ -199,3 +199,42 @@ def perform_create(self, serializer): category = serializer.validated_data["category"] send_email_feedback(email, message, category) + + +class SendMessageView(CreateAPIView): + """ + API endpoint for sending a custom email message to a specific user. + + This view allows administrators to send a message to a user's registered email. + It validates the request payload, retrieves the user based on the provided ID, + and sends the email using the specified category and message content. + """ + + queryset = CustomUser.objects.all() + permission_classes = [IsStaffUser] + serializer_class = FeedbackSerializer + + def perform_create(self, serializer): + """ + Handles the email sending logic after successful validation. + + This method is executed after the request data has been validated + by the serializer. It retrieves the user, validates their existence, + and sends the email with the provided category and message content. + + Parameters: + serializer (FeedbackSerializer): The serializer instance containing + the validated data from the request. + """ + user = self.get_object() + email = serializer.validated_data["email"] + category = serializer.validated_data["category"] + message_content = serializer.validated_data["message"] + + send_email_to_user( + user=user, + category=category, + message_content=message_content, + email=email, + sender_name="Адміністратор CraftMerge", + ) diff --git a/BackEnd/utils/administration/send_email_notification.py b/BackEnd/utils/administration/send_email_notification.py new file mode 100644 index 00000000..003c881b --- /dev/null +++ b/BackEnd/utils/administration/send_email_notification.py @@ -0,0 +1,58 @@ +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.conf import settings +from django.core.validators import validate_email +from django.core.exceptions import ValidationError + +EMAIL_CONTENT_SUBTYPE = "html" +PROTOCOL = "http" + + +def send_email_to_user( + user, + category, + message_content, + email=None, + sender_name="Адміністратор CraftMerge", + template_name="administration/admin_message_template.html", +): + """ + Sends an email message to the user using the specified template. + + :param user: The user object (CustomUser) + :param category: The email category + :param message_content: The message content + :param email: (Optional) The recipient's email + :param sender_name: Name of the sender + :param template_name: The path to the HTML template + """ + if not category: + raise ValueError("Category is required.") + if not message_content.strip(): + raise ValueError("Message content cannot be empty.") + try: + validate_email(email or user.email) + except ValidationError: + raise ValueError("Invalid email address.") + + context = { + "user_name": f"{user.name} {user.surname}", + "message": message_content, + "category": category, + "sender_name": sender_name, + "logo_url": f"{PROTOCOL}://178.212.110.52/craftMerge-logo.png", + } + + email_body = render_to_string(template_name, context) + recipient_email = email if email else user.email + + subject = f"{sender_name} - {category}" + + email = EmailMultiAlternatives( + subject=subject, + body=email_body, + from_email=settings.EMAIL_HOST_USER, + to=[recipient_email], + ) + email.content_subtype = EMAIL_CONTENT_SUBTYPE + email.send(fail_silently=False) diff --git a/FrontEnd/public/craftMerge-logo.png b/FrontEnd/public/craftMerge-logo.png new file mode 100644 index 00000000..842d972f Binary files /dev/null and b/FrontEnd/public/craftMerge-logo.png differ diff --git a/FrontEnd/src/pages/AdminPage/UserProfilesTable/UserActions.jsx b/FrontEnd/src/pages/AdminPage/UserProfilesTable/UserActions.jsx new file mode 100644 index 00000000..56db1e9a --- /dev/null +++ b/FrontEnd/src/pages/AdminPage/UserProfilesTable/UserActions.jsx @@ -0,0 +1,155 @@ +import React, { useState } from 'react'; +import { Dropdown, Modal, Button, Select, Input, Tooltip } from 'antd'; +import { toast } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import axios from 'axios'; +import PropTypes from 'prop-types'; +import styles from './UserActions.module.css'; +import { useNavigate } from 'react-router-dom'; + +function UserActions({ user, onActionComplete }) { + const [selectedCategory, setSelectedCategory] = useState('Інше'); + const [messageContent, setMessageContent] = useState(''); + const [isSending, setIsSending] = useState(false); + const [isModalVisible, setIsModalVisible] = useState(false); + const [error, setError] = useState(''); + const navigate = useNavigate(); + + const validateMessage = (message) => { + if (message.trim().length >= 10) { + setError(''); + return true; + } else { + setError('Повідомлення має бути не менше 10 символів.'); + return false; + } + }; + + const handleSendMessage = async () => { + if (!validateMessage(messageContent)) return; + + setIsSending(true); + try { + await axios.post( + `${process.env.REACT_APP_BASE_API_URL}/api/admin/users/${user.id}/send_message/`, + { + email: user.email, + category: selectedCategory, + message: messageContent.trim(), + } + ); + toast.success('Повідомлення успішно надіслано'); + setMessageContent(''); + setIsModalVisible(false); + if (onActionComplete) onActionComplete(); + } catch { + toast.error('Не вдалося надіслати повідомлення. Спробуйте ще раз.'); + } finally { + setIsSending(false); + } + }; + + const viewProfile = () => { + try { + navigate(`/customadmin/users/${user.id}`); + } catch (error) { + toast.error('Не вдалося переглянути профіль. Спробуйте оновити сторінку.'); + } + }; + + const menuItems = [ + { + key: 'sendMessage', + label: ( + <Tooltip title="Відправити повідомлення на email"> + Надіслати листа + </Tooltip> + ), + onClick: () => setIsModalVisible(true), + }, + { + key: 'viewProfile', + label: ( + <Tooltip title="Переглянути детальний профіль користувача"> + Переглянути профіль + </Tooltip> + ), + onClick: viewProfile, + }, + ]; + + return ( + <> + <Dropdown menu={{ items: menuItems }} trigger={['click']}> + <Button>Обрати</Button> + </Dropdown> + <Modal + title={`Надіслати листа користувачу ${user.name} ${user.surname}`} + open={isModalVisible} + onCancel={() => { + setIsModalVisible(false); + setError(''); + setMessageContent(''); + }} + footer={[ + <Button key="cancel" onClick={() => setIsModalVisible(false)}> + Відмінити + </Button>, + <Button + key="send" + type="primary" + loading={isSending} + onClick={handleSendMessage} + > + Відправити + </Button>, + ]} + width={600} + > + <div className={styles.userActionsModalContent}> + <Input + value={user.email} + readOnly + className={styles.userActionsInput} + addonBefore="Email" + /> + <Select + defaultValue="Інше" + className={styles.userActionsSelect} + onChange={(value) => setSelectedCategory(value)} + options={[ + { value: 'Технічне питання', label: 'Технічне питання' }, + { value: 'Рекомендації', label: 'Рекомендації' }, + { value: 'Питання', label: 'Питання' }, + { value: 'Інше', label: 'Інше' }, + ]} + /> + <Input.TextArea + rows={6} + placeholder="Введіть ваше повідомлення..." + value={messageContent} + onChange={(e) => { + const input = e.target.value; + setMessageContent(input); + validateMessage(input); + }} + className={styles.userActionsTextarea} + /> + {error && <p className={styles.userActionsError}>{error}</p>} + </div> + </Modal> + </> + ); +} + +UserActions.propTypes = { + user: PropTypes.shape({ + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + surname: PropTypes.string.isRequired, + email: PropTypes.string.isRequired, + }).isRequired, + onActionComplete: PropTypes.func, +}; + +export default UserActions; diff --git a/FrontEnd/src/pages/AdminPage/UserProfilesTable/UserActions.module.css b/FrontEnd/src/pages/AdminPage/UserProfilesTable/UserActions.module.css new file mode 100644 index 00000000..bf44ba9b --- /dev/null +++ b/FrontEnd/src/pages/AdminPage/UserProfilesTable/UserActions.module.css @@ -0,0 +1,24 @@ +.userActionsModalContent { + display: flex; + flex-direction: column; + gap: 15px; + width: 100%; +} + +.userActionsInput { + width: 100%; +} + +.userActionsSelect { + width: 100%; +} + +.userActionsTextarea { + width: 100%; +} + +.userActionsError { + color: red; + font-size: 12px; + margin: 0; +} diff --git a/FrontEnd/src/pages/AdminPage/UserProfilesTable/UserTable.jsx b/FrontEnd/src/pages/AdminPage/UserProfilesTable/UserTable.jsx index 97b84538..760185e1 100644 --- a/FrontEnd/src/pages/AdminPage/UserProfilesTable/UserTable.jsx +++ b/FrontEnd/src/pages/AdminPage/UserProfilesTable/UserTable.jsx @@ -2,10 +2,11 @@ import { useState, useEffect } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import Highlighter from 'react-highlight-words'; import axios from 'axios'; -import useSWR from 'swr'; +import useSWR, { mutate }from 'swr'; import { Table, Tag, Tooltip, Pagination, Input, Button, Space } from 'antd'; import { CaretUpOutlined, CaretDownOutlined, SearchOutlined } from '@ant-design/icons'; import css from './UserTable.module.scss'; +import UserActions from './UserActions'; const LENGTH_EMAIL = 14; @@ -238,6 +239,14 @@ function UserTable() { title: 'Дії', dataIndex: 'actions', key: 'actions', + render: (_, user) => ( + <UserActions + user={user} + onActionComplete={() => { + mutate(url); + }} + /> + ), }, ];