diff --git a/api/core/dependencies/email/templates/faq-feedback.html b/api/core/dependencies/email/templates/faq-feedback.html new file mode 100644 index 000000000..c0aa333f6 --- /dev/null +++ b/api/core/dependencies/email/templates/faq-feedback.html @@ -0,0 +1,51 @@ +{% extends 'admin-base.html' %} + +{% block title %} + Magic Link +{% endblock %} + +{% block content %} +
+
+

Dear {{ full_name }},

+

Thank you for your inquiry! We're processing your request, and here's a copy of for your records:

+
+
+

User Details

+ +

Message

+

+ {{ message }} +

+
+
+{% endblock %} + diff --git a/api/utils/send_mail.py b/api/utils/send_mail.py index 26da36537..c027f0c67 100644 --- a/api/utils/send_mail.py +++ b/api/utils/send_mail.py @@ -57,4 +57,26 @@ def send_mail_handler(sender, reciever, html, subject): with smtplib.SMTP_SSL(settings.MAIL_SERVER, settings.MAIL_PORT) as server: server.login(settings.MAIL_USERNAME, settings.MAIL_PASSWORD) - server.sendmail(sender, reciever, message.as_string()) \ No newline at end of file + server.sendmail(sender, reciever, message.as_string()) + + +def send_faq_inquiry_mail(context: dict): + from main import email_templates + sender_email = settings.MAIL_USERNAME + receiver_email = context.get('email') + password = settings.MAIL_PASSWORD + + html = email_templates.get_template("faq-feedback.html").render(context) + + message = MIMEMultipart("alternative") + message["Subject"] = "We've received your inquiry" + message["From"] = sender_email + message["To"] = receiver_email + + part = MIMEText(html, "html") + + message.attach(part) + + with smtplib.SMTP_SSL(settings.MAIL_SERVER, settings.MAIL_PORT) as server: + server.login(sender_email, password) + server.sendmail(sender_email, receiver_email, message.as_string()) diff --git a/api/v1/models/__init__.py b/api/v1/models/__init__.py index 2fb26c368..943c3e100 100644 --- a/api/v1/models/__init__.py +++ b/api/v1/models/__init__.py @@ -28,3 +28,4 @@ from api.v1.models.privacy import PrivacyPolicy from api.v1.models.terms import TermsAndConditions from api.v1.models.reset_password_token import ResetPasswordToken +from api.v1.models.faq_inquiries import FAQInquiries diff --git a/api/v1/models/faq_inquiries.py b/api/v1/models/faq_inquiries.py new file mode 100644 index 000000000..428b02a37 --- /dev/null +++ b/api/v1/models/faq_inquiries.py @@ -0,0 +1,10 @@ +from sqlalchemy import Column, String, Text +from api.v1.models.base_model import BaseTableModel + + +class FAQInquiries(BaseTableModel): + __tablename__ = "faq_inquiries" + + email = Column(String, nullable=False) + full_name = Column(String, nullable=False) + message = Column(Text, nullable=False) diff --git a/api/v1/routes/__init__.py b/api/v1/routes/__init__.py index ccea261a2..8b7ee2a7e 100644 --- a/api/v1/routes/__init__.py +++ b/api/v1/routes/__init__.py @@ -3,6 +3,7 @@ from api.v1.routes.team import team from fastapi import APIRouter from api.v1.routes.auth import auth +from api.v1.routes.faq_inquiries import faq_inquiries from api.v1.routes.newsletter import newsletter, news_sub from api.v1.routes.user import user_router from api.v1.routes.product import product, non_organisation_product @@ -48,6 +49,7 @@ api_version_one = APIRouter(prefix="/api/v1") api_version_one.include_router(auth) +api_version_one.include_router(faq_inquiries) api_version_one.include_router(google_auth) api_version_one.include_router(fb_auth) api_version_one.include_router(pwd_reset) diff --git a/api/v1/routes/faq_inquiries.py b/api/v1/routes/faq_inquiries.py new file mode 100644 index 000000000..47ac2291b --- /dev/null +++ b/api/v1/routes/faq_inquiries.py @@ -0,0 +1,66 @@ +from fastapi import APIRouter, status, BackgroundTasks, Depends +from api.core.responses import SUCCESS +from api.db.database import get_db +from api.utils.send_mail import send_faq_inquiry_mail +from api.utils.success_response import success_response +from api.v1.models.user import User +from api.v1.schemas.faq_inquiries import CreateFAQInquiry +from api.v1.services.faq_inquiries import faq_inquiries_service +from api.v1.services.user import user_service +from sqlalchemy.orm import Session +from typing import Annotated + +faq_inquiries = APIRouter(prefix="/faq-inquiries", tags=["FAQ-Inquiries"]) + +# CREATE +@faq_inquiries.post( + "", + response_model=success_response, + status_code=status.HTTP_201_CREATED, + responses={ + 201: {"description": "FAQ Inquiry created successfully"}, + 422: {"description": "Validation Error"}, + }, +) +async def create_faq_inquiry( + data: CreateFAQInquiry, db: Annotated[Session, Depends(get_db)], + background_tasks: BackgroundTasks, +): + """Add a new FAQ Inquiry.""" + new_faq_inquiry = faq_inquiries_service.create(db, data) + + # Send email to admin + background_tasks.add_task( + send_faq_inquiry_mail, + context={ + "full_name": new_faq_inquiry.full_name, + "email": new_faq_inquiry.email, + "message": new_faq_inquiry.message, + } + ) + + response = success_response( + message=SUCCESS, + data={"id": new_faq_inquiry.id}, + status_code=status.HTTP_201_CREATED, + ) + return response + +# READ +@faq_inquiries.get( + "", + response_model=success_response, + status_code=200 +) +async def get_all_faq_inquiries( + db: Annotated[Session, Depends(get_db)], + admin: User = Depends(user_service.get_current_super_admin), +): + """Fetch all FAQ Inquiries.""" + faq_inquiries = faq_inquiries_service.fetch_all(db) + response = success_response( + message=SUCCESS, + data=faq_inquiries, + status_code=status.HTTP_200_OK, + ) + return response \ No newline at end of file diff --git a/api/v1/schemas/faq_inquiries.py b/api/v1/schemas/faq_inquiries.py new file mode 100644 index 000000000..d636eb0e9 --- /dev/null +++ b/api/v1/schemas/faq_inquiries.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel, EmailStr, Field + +class CreateFAQInquiry(BaseModel): + """Validate the FAQ Inquiry form data.""" + + full_name: str = Field(..., example="John Doe") + email: EmailStr = Field(..., example="johndoe@gmail.com") + message: str = Field(..., example="I have a question about the product.") \ No newline at end of file diff --git a/api/v1/services/faq_inquiries.py b/api/v1/services/faq_inquiries.py new file mode 100644 index 000000000..a3d3269b1 --- /dev/null +++ b/api/v1/services/faq_inquiries.py @@ -0,0 +1,79 @@ +from fastapi import Depends +from api.core.base.services import Service +from api.v1.models.faq_inquiries import FAQInquiries +from api.v1.schemas.faq_inquiries import CreateFAQInquiry +from sqlalchemy.orm import Session +from typing import Annotated, Optional, Any +from api.v1.routes.faq_inquiries import get_db + + +class FAQInquiryService(Service): + """FAQ Inquiry Service.""" + + def __init__(self) -> None: + self.adabtingMapper = { + "full_name": "full_name", + "email": "email", + "message": "message", + } + super().__init__() + + # ------------ CRUD functions ------------ # + # CREATE + def create(self, db: Annotated[Session, Depends(get_db)], data: CreateFAQInquiry): + """Create a new FAQ Inquiry.""" + faq_inquiry = FAQInquiries( + full_name=getattr(data, self.adabtingMapper["full_name"]), + email=getattr(data, self.adabtingMapper["email"]), + message=getattr(data, self.adabtingMapper["message"]), + ) + db.add(faq_inquiry) + db.commit() + db.refresh(faq_inquiry) + return faq_inquiry + + # READ + def fetch_all(self, db: Session, **query_params: Optional[Any]): + """Fetch all submisions with option to search using query parameters""" + + query = db.query(FAQInquiries) + + # Enable filter by query parameter + if query_params: + for column, value in query_params.items(): + if hasattr(FAQInquiries, column) and value: + query = query.filter(getattr(FAQInquiries, column).ilike(f"%{value}%")) + + return query.all() + + def fetch(self, db: Session, id: str): + """Fetches a faq_inquiry by id""" + + faq_inquiry = db.query(FAQInquiries).get(id) + return faq_inquiry + + def fetch_by_email(self, db: Session, email: str): + """Fetches a faq_inquiry by email""" + + faq_inquiry = db.query(FAQInquiries).filter(FAQInquiries.email == email).first() + return faq_inquiry + + def delete(self, db: Session, id: str): + """Delete a faq_inquiry by id""" + + faq_inquiry = db.query(FAQInquiries).get(id) + db.delete(faq_inquiry) + db.commit() + return faq_inquiry + + def update(self, db: Session, id: str, data: CreateFAQInquiry): + faq_inquiry = db.query(FAQInquiries).get(id) + faq_inquiry.full_name = data.full_name + faq_inquiry.email = data.email + faq_inquiry.message = data.message + db.commit() + db.refresh(faq_inquiry) + return faq_inquiry + + +faq_inquiries_service = FAQInquiryService() \ No newline at end of file diff --git a/tests/v1/faq-inquiries/test_faq_inquiries.py b/tests/v1/faq-inquiries/test_faq_inquiries.py new file mode 100644 index 000000000..91801f88d --- /dev/null +++ b/tests/v1/faq-inquiries/test_faq_inquiries.py @@ -0,0 +1,65 @@ +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from uuid_extensions import uuid7 + +from api.db.database import get_db +from api.utils.send_mail import send_faq_inquiry_mail +from api.v1.models.faq_inquiries import FAQInquiries +from main import app + + + +@pytest.fixture +def db_session_mock(): + db_session = MagicMock(spec=Session) + return db_session + +@pytest.fixture +def client(db_session_mock): + app.dependency_overrides[get_db] = lambda: db_session_mock + client = TestClient(app) + yield client + app.dependency_overrides = {} + + +def mock_post_inquiry(): + return FAQInquiries( + id=str(uuid7()), + full_name="John Doe", + email="john.doe@gmail.com", + message="I have a question about the product.", + ) + + +@patch('fastapi.BackgroundTasks.add_task') +@patch("api.v1.services.faq_inquiries.faq_inquiries_service.create") +def test_submit_faq_inquiries(mock_post_inquiry_form, mock_add_task, db_session_mock, client): + """Tests the POST /api/v1/newsletter-subscription endpoint to ensure successful subscription with valid input.""" + + mock_post_inquiry_form.return_value = mock_post_inquiry() + + db_session_mock.add.return_value = None + db_session_mock.commit.return_value = None + db_session_mock.refresh.return_value = None + + response = client.post('/api/v1/faq-inquiries', json={ + "full_name": "John Doe", + "email": "johndoe@gmail.com", + "message": "I have a question about the product." + }) + + assert response.status_code == 201 + + mock_add_task.assert_called_once() + mock_add_task.assert_called_with( + send_faq_inquiry_mail, + context={ + "full_name": "John Doe", + "email": "john.doe@gmail.com", + "message": "I have a question about the product.", + } + )