├── tests ├── __init__.py ├── features │ ├── auth │ │ ├── __init__.py │ │ ├── test_commands │ │ │ └── __init__.py │ │ ├── test_queries │ │ │ └── __init__.py │ │ ├── test_auth_service.py │ │ ├── test_controller.py │ │ ├── test_verification.py │ │ └── test_authorization.py │ ├── user │ │ ├── __init__.py │ │ ├── test_commands │ │ │ └── __init__.py │ │ ├── test_queries │ │ │ └── __init__.py │ │ ├── models.py │ │ ├── test_repository.py │ │ └── test_controller.py │ ├── answers │ │ ├── __init__.py │ │ ├── test_commands │ │ │ └── __init__.py │ │ ├── test_queries │ │ │ └── __init__.py │ │ ├── models.py │ │ └── test_controller.py │ ├── questions │ │ ├── __init__.py │ │ ├── test_commands │ │ │ └── __init__.py │ │ ├── test_queries │ │ │ └── __init__.py │ │ ├── models.py │ │ ├── test_repository.py │ │ └── test_controller.py │ └── vote │ │ └── test_controller.py ├── test_main.py ├── conftest.py └── core │ └── test_base_repository.py ├── qna_api ├── __init__.py ├── features │ ├── auth │ │ ├── __init__.py │ │ ├── models.py │ │ ├── controller.py │ │ └── auth_service.py │ ├── user │ │ ├── __init__.py │ │ ├── constants.py │ │ ├── repository.py │ │ ├── models.py │ │ ├── commands │ │ │ ├── validate_user_command.py │ │ │ ├── signup_command.py │ │ │ └── update_user_command.py │ │ └── controller.py │ ├── vote │ │ ├── __init__.py │ │ ├── models.py │ │ ├── repository.py │ │ ├── commands │ │ │ ├── add_answer_vote_command.py │ │ │ └── add_question_vote_command.py │ │ └── controller.py │ ├── admin │ │ ├── __init__.py │ │ ├── queries │ │ │ └── get_all_users_query.py │ │ ├── commands │ │ │ ├── delete_user_command.py │ │ │ └── enable_user_command.py │ │ └── controller.py │ ├── questions │ │ ├── __init__.py │ │ ├── queries │ │ │ ├── get_all_questions_query.py │ │ │ ├── get_question_query.py │ │ │ └── get_full_question_query.py │ │ ├── commands │ │ │ ├── delete_question_command.py │ │ │ ├── update_question_command.py │ │ │ └── create_question_command.py │ │ ├── models.py │ │ ├── repository.py │ │ └── controller.py │ └── answers │ │ ├── models.py │ │ ├── repository.py │ │ ├── queries │ │ ├── get_answer_query.py │ │ └── get_question_answers_query.py │ │ ├── commands │ │ ├── delete_answer_command.py │ │ ├── update_answer_command.py │ │ └── add_answer_command.py │ │ └── controller.py ├── infrastructure │ ├── __init__.py │ ├── email_service.py │ └── external_api_service.py ├── domain │ ├── role.py │ ├── question.py │ ├── answer.py │ ├── vote.py │ └── user.py ├── crosscutting │ ├── logging.py │ ├── authorization.py │ └── notification_service.py ├── .env.example ├── core │ ├── config.py │ ├── database.py │ ├── base_repository.py │ └── constants.py └── main.py ├── tox.ini ├── requirements.txt ├── docker-compose.yml ├── Dockerfile ├── .vscode ├── tasks.json ├── settings.json └── launch.json ├── LICENSE ├── README.md └── .gitignore /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qna_api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qna_api/features/auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qna_api/features/user/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qna_api/features/vote/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/features/auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/features/user/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qna_api/features/admin/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qna_api/features/questions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qna_api/infrastructure/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/features/answers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/features/questions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qna_api/infrastructure/email_service.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qna_api/infrastructure/external_api_service.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/features/answers/test_commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/features/answers/test_queries/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/features/auth/test_commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/features/auth/test_queries/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/features/user/test_commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/features/user/test_queries/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/features/questions/test_commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/features/questions/test_queries/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | omit = 3 | tests/* 4 | -------------------------------------------------------------------------------- /qna_api/domain/role.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class Role(Enum): 4 | ADMIN = "admin" 5 | USER = "user" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bcrypt==4.0.1 2 | debugpy 3 | fastapi 4 | mediatr 5 | passlib 6 | passlib[bcrypt] 7 | pydantic 8 | pydantic-settings 9 | pytest 10 | pytest-cov 11 | pytest-dotenv 12 | pytest-mock 13 | python-jose 14 | sqlalchemy 15 | uvicorn -------------------------------------------------------------------------------- /qna_api/features/user/constants.py: -------------------------------------------------------------------------------- 1 | # Route descriptions 2 | SIGNUP_DESCRIPTION = "Register as a new user in the system." 3 | ME_DESCRIPTION = "Retrieve your user's information." 4 | UPDATE_USER_DESCRIPTION = "Update your user's information." 5 | VALIDATE_USER_DESCRIPTION = "Validate your user's email." 6 | 7 | -------------------------------------------------------------------------------- /tests/features/answers/models.py: -------------------------------------------------------------------------------- 1 | from qna_api.features.answers.models import Answer 2 | 3 | mock_answer = Answer( 4 | id=1, 5 | content="This is an answer", 6 | question_id=1, 7 | user_id=1 8 | ) 9 | 10 | mock_updated_answer = Answer( 11 | id=1, 12 | content="This is an updated answer", 13 | question_id=1, 14 | user_id=1 15 | ) 16 | 17 | -------------------------------------------------------------------------------- /qna_api/features/answers/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | class AnswerCreate(BaseModel): 4 | content: str 5 | 6 | class AnswerUpdate(BaseModel): 7 | content: str 8 | 9 | class Answer(BaseModel): 10 | id: int 11 | content: str 12 | question_id: int 13 | user_id: int 14 | 15 | class Config: 16 | from_attributes = True 17 | -------------------------------------------------------------------------------- /qna_api/features/auth/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, EmailStr 2 | from sqlalchemy import Column, Integer, String 3 | from qna_api.core.database import Base 4 | 5 | # Modelos Pydantic 6 | class Token(BaseModel): 7 | access_token: str 8 | token_type: str 9 | 10 | class TokenData(BaseModel): 11 | username: str | None = None 12 | roles: str | None = None -------------------------------------------------------------------------------- /qna_api/features/vote/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | class VoteCreate(BaseModel): 4 | vote_value: int # 1 for upvote, -1 for downvote 5 | 6 | class Vote(BaseModel): 7 | id: int 8 | vote_value: int 9 | user_id: int 10 | question_id: int | None 11 | answer_id: int | None 12 | 13 | class Config: 14 | from_attributes = True 15 | -------------------------------------------------------------------------------- /qna_api/crosscutting/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from qna_api.core import constants 3 | 4 | def get_logger(name): 5 | logging.basicConfig( 6 | level=logging.INFO, 7 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 8 | datefmt='%Y-%m-%d %H:%M:%S' 9 | ) 10 | if name is None: 11 | name = constants.APP_NAME 12 | return logging.getLogger(name) 13 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | qa_api: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | ports: 9 | - "8000:8000" 10 | - "5678:5678" # Port for debugpy 11 | environment: 12 | - DEBUG_MODE=true 13 | volumes: 14 | - .:/app 15 | command: ["sh", "-c", "python -m debugpy --wait-for-client --listen 0.0.0.0:5678 -m uvicorn qa_api.main:app --host 0.0.0.0 --port 8000 --reload"] 16 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | from qna_api.main import app 3 | 4 | client = TestClient(app) 5 | 6 | def test_redirect_to_swagger(): 7 | """Tests redirect to Swagger endpoint""" 8 | response = client.get("/") 9 | assert response.status_code == 200 10 | assert response.url.path.endswith("/docs") 11 | 12 | def test_init_db_not_called(): 13 | from qna_api.core.database import init_db 14 | assert init_db() is None -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12 2 | 3 | # Keeps Python from generating .pyc files in the container 4 | ENV PYTHONDONTWRITEBYTECODE=1 5 | 6 | # Turns off buffering for easier container logging 7 | ENV PYTHONUNBUFFERED=1 8 | 9 | # Install pip requirements 10 | COPY requirements.txt . 11 | RUN pip install --no-cache-dir -r requirements.txt 12 | 13 | WORKDIR /app 14 | COPY . /app 15 | 16 | EXPOSE 8000 17 | # Expose the debugpy port 18 | EXPOSE 5678 19 | 20 | 21 | CMD ["uvicorn", "qa_api.main:app", "--host", "0.0.0.0", "--port", "8000"] -------------------------------------------------------------------------------- /qna_api/features/vote/repository.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | from qna_api.core.base_repository import BaseRepository 3 | from qna_api.domain.vote import VoteEntity 4 | from qna_api.core.database import get_db 5 | 6 | class VoteRepository(BaseRepository[VoteEntity]): 7 | def __init__(self, db: Session): 8 | super().__init__(VoteEntity, db) 9 | 10 | @classmethod 11 | def instance(cls): 12 | if cls._instance is None: 13 | db = next(get_db()) 14 | cls._instance = cls(db) 15 | return cls._instance 16 | -------------------------------------------------------------------------------- /qna_api/features/answers/repository.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | from qna_api.core.base_repository import BaseRepository 3 | from qna_api.domain.answer import AnswerEntity 4 | from qna_api.core.database import get_db 5 | 6 | class AnswerRepository(BaseRepository[AnswerEntity]): 7 | def __init__(self, db: Session): 8 | super().__init__(AnswerEntity, db) 9 | 10 | @classmethod 11 | def instance(cls): 12 | if cls._instance is None: 13 | db = next(get_db()) 14 | cls._instance = cls(db) 15 | return cls._instance -------------------------------------------------------------------------------- /qna_api/.env.example: -------------------------------------------------------------------------------- 1 | API_VERSION=0.0.1 2 | DEBUG_MODE=True 3 | 4 | DATABASE_URL=sqlite:///./qna.db 5 | INITIAL_ADMIN_USERNAME=admin 6 | INITIAL_ADMIN_EMAIL=juan@jgcarmona.com 7 | INITIAL_ADMIN_PASSWORD=P@ssw0rd! 8 | SECRET_KEY=your_secret_key 9 | ALGORITHM=HS256 10 | ACCESS_TOKEN_EXPIRE_MINUTES=30 11 | 12 | # You can perfectly use your gmail account for this ;) 13 | 14 | USE_SMTP=False # If you want to use SMTP, set this to True 15 | SMTP_SERVER=your.smtp.server.com 16 | SMTP_PORT=1234 17 | SMTP_USERNAME=your_user@your_domain.com 18 | SMTP_PASSWORD=your_password 19 | SENDER_EMAIL=your_user@your_domain.com -------------------------------------------------------------------------------- /qna_api/domain/question.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String, ForeignKey 2 | from sqlalchemy.orm import relationship 3 | from qna_api.core.database import Base 4 | 5 | class QuestionEntity(Base): 6 | __tablename__ = "questions" 7 | 8 | id = Column(Integer, primary_key=True, index=True) 9 | title = Column(String, index=True) 10 | description = Column(String) 11 | user_id = Column(Integer, ForeignKey('users.id')) 12 | 13 | user = relationship("UserEntity", back_populates="questions") 14 | answers = relationship("AnswerEntity", back_populates="question") 15 | -------------------------------------------------------------------------------- /qna_api/domain/answer.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String, ForeignKey 2 | from sqlalchemy.orm import relationship 3 | from qna_api.core.database import Base 4 | 5 | class AnswerEntity(Base): 6 | __tablename__ = "answers" 7 | 8 | id = Column(Integer, primary_key=True, index=True) 9 | content = Column(String) 10 | question_id = Column(Integer, ForeignKey('questions.id')) 11 | user_id = Column(Integer, ForeignKey('users.id')) 12 | 13 | question = relationship("QuestionEntity", back_populates="answers") 14 | user = relationship("UserEntity", back_populates="answers") -------------------------------------------------------------------------------- /qna_api/features/questions/queries/get_all_questions_query.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | from qna_api.features.questions.models import Question 3 | from qna_api.features.questions.repository import QuestionRepository 4 | 5 | class GetAllQuestionsQuery: 6 | pass 7 | 8 | @Mediator.handler 9 | class GetAllQuestionsQueryHandler: 10 | def __init__(self): 11 | self.question_repository = QuestionRepository.instance() 12 | 13 | def handle(self, request: GetAllQuestionsQuery) -> list[Question]: 14 | questions = self.question_repository.get_all() 15 | return [Question.model_validate(q, from_attributes=True) for q in questions] 16 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Docker: Build and Run", 6 | "type": "shell", 7 | "command": "docker build -t qna_api . && docker run -d -p 8000:8000 -p 5678:5678 --name qa-api-container -e DEBUG_MODE=true qna_api", 8 | "problemMatcher": [], 9 | "isBackground": false 10 | }, 11 | { 12 | "label": "Docker: Stop and Remove", 13 | "type": "shell", 14 | "command": "docker stop qa-api-container && docker rm qa-api-container", 15 | "problemMatcher": [], 16 | "isBackground": false 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /qna_api/features/user/repository.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | from qna_api.core.base_repository import BaseRepository 3 | from qna_api.domain.user import UserEntity 4 | from qna_api.core.database import get_db 5 | 6 | class UserRepository(BaseRepository[UserEntity]): 7 | def __init__(self, db: Session): 8 | super().__init__(UserEntity, db) 9 | 10 | @classmethod 11 | def instance(cls): 12 | if cls._instance is None: 13 | db = next(get_db()) 14 | cls._instance = cls(db) 15 | return cls._instance 16 | 17 | def get_by_username(self, username: str) -> UserEntity: 18 | return self.db.query(UserEntity).filter(UserEntity.username == username).first() -------------------------------------------------------------------------------- /tests/features/user/models.py: -------------------------------------------------------------------------------- 1 | from qna_api.features.user.models import SignupResponse, User 2 | 3 | mock_user = User( 4 | id=1, 5 | username="testuser", 6 | email="testuser@example.com", 7 | full_name="Test User", 8 | disabled=False 9 | ) 10 | 11 | mock_new_user = SignupResponse( 12 | user=User( 13 | id=2, 14 | username="newuser", 15 | email="newuser@example.com", 16 | full_name="New User", 17 | password="password123", 18 | ), 19 | message="User created successfully" 20 | ) 21 | 22 | mock_updated_user = User( 23 | id=1, 24 | username="updateduser", 25 | email="updateduser@example.com", 26 | full_name="Updated User", 27 | password="password123" 28 | ) 29 | 30 | -------------------------------------------------------------------------------- /tests/features/questions/models.py: -------------------------------------------------------------------------------- 1 | from qna_api.features.questions.models import Question, FullQuestion 2 | from qna_api.features.answers.models import Answer 3 | 4 | # Some useful mock data 5 | mock_question = Question( 6 | id=1, 7 | title="Sample Question", 8 | description="This is a sample question", 9 | user_id=1, 10 | answers=[], 11 | 12 | ) 13 | 14 | mock_answer = Answer( 15 | id=1, 16 | question_id=1, 17 | user_id=1, 18 | content="This is an answer" 19 | ) 20 | 21 | mock_full_question = FullQuestion( 22 | id=1, 23 | title="Sample Question", 24 | description="This is a sample question", 25 | user_id=1, 26 | answers=[ 27 | mock_answer 28 | ] 29 | ) 30 | 31 | mock_questions_list = [mock_question] 32 | -------------------------------------------------------------------------------- /qna_api/features/admin/queries/get_all_users_query.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | from qna_api.features.user.repository import UserRepository 3 | from qna_api.features.user.models import User 4 | from qna_api.crosscutting.logging import get_logger 5 | 6 | logger = get_logger(__name__) 7 | 8 | class GetAllUsersQuery: 9 | pass 10 | 11 | @Mediator.handler 12 | class GetAllUsersQueryHandler: 13 | def __init__(self): 14 | self.user_repository = UserRepository.instance() 15 | 16 | def handle(self, query: GetAllUsersQuery) -> list[User]: 17 | users = self.user_repository.get_all() 18 | if not users: 19 | raise ValueError("No users found") 20 | 21 | return [User.model_validate(user, from_attributes=True) for user in users] 22 | -------------------------------------------------------------------------------- /qna_api/features/questions/queries/get_question_query.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | from qna_api.features.questions.models import Question 3 | from qna_api.features.questions.repository import QuestionRepository 4 | 5 | class GetQuestionQuery: 6 | def __init__(self, question_id: int): 7 | self.question_id = question_id 8 | 9 | @Mediator.handler 10 | class GetQuestionQueryHandler: 11 | def __init__(self): 12 | self.question_repository = QuestionRepository.instance() 13 | 14 | def handle(self, request: GetQuestionQuery) -> Question: 15 | question = self.question_repository.get(request.question_id) 16 | if not question: 17 | raise ValueError("Question not found") 18 | return Question.model_validate(question, from_attributes=True) 19 | -------------------------------------------------------------------------------- /qna_api/domain/vote.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, ForeignKey, DateTime 2 | from sqlalchemy.orm import relationship 3 | from datetime import datetime, timezone 4 | from qna_api.core.database import Base 5 | 6 | class VoteEntity(Base): 7 | __tablename__ = "votes" 8 | 9 | id = Column(Integer, primary_key=True, index=True) 10 | user_id = Column(Integer, ForeignKey('users.id')) 11 | question_id = Column(Integer, ForeignKey('questions.id'), nullable=True) 12 | answer_id = Column(Integer, ForeignKey('answers.id'), nullable=True) 13 | vote_value = Column(Integer) 14 | created_at = Column(DateTime, default=datetime.now(timezone.utc)) 15 | 16 | user = relationship("UserEntity") 17 | question = relationship("QuestionEntity") 18 | answer = relationship("AnswerEntity") 19 | -------------------------------------------------------------------------------- /qna_api/features/admin/commands/delete_user_command.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | from qna_api.features.user.repository import UserRepository 3 | from qna_api.crosscutting.logging import get_logger 4 | 5 | logger = get_logger(__name__) 6 | 7 | class DeleteUserCommand: 8 | def __init__(self, user_id: int): 9 | self.user_id = user_id 10 | 11 | @Mediator.handler 12 | class DeleteUserCommandHandler: 13 | def __init__(self): 14 | self.user_repository = UserRepository.instance() 15 | 16 | def handle(self, command: DeleteUserCommand) -> None: 17 | logger.info(f"Deleting user with id: {command.user_id}") 18 | try: 19 | self.user_repository.delete(command.user_id) 20 | except Exception as e: 21 | raise ValueError(f"Error deleting user: {str(e)}") 22 | -------------------------------------------------------------------------------- /qna_api/features/questions/queries/get_full_question_query.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | from qna_api.features.questions.models import FullQuestion 3 | from qna_api.features.questions.repository import QuestionRepository 4 | 5 | class GetFullQuestionQuery: 6 | def __init__(self, question_id: int): 7 | self.question_id = question_id 8 | 9 | @Mediator.handler 10 | class GetFullQuestionQueryHandler: 11 | def __init__(self): 12 | self.question_repository = QuestionRepository.instance() 13 | 14 | def handle(self, request: GetFullQuestionQuery) -> FullQuestion: 15 | question = self.question_repository.get_full_question(request.question_id) 16 | if not question: 17 | raise ValueError("Question not found") 18 | return FullQuestion.model_validate(question, from_attributes=True) 19 | -------------------------------------------------------------------------------- /qna_api/features/answers/queries/get_answer_query.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | from qna_api.features.answers.repository import AnswerRepository 3 | from qna_api.features.answers.models import Answer 4 | from qna_api.crosscutting.logging import get_logger 5 | 6 | logger = get_logger(__name__) 7 | 8 | class GetAnswerQuery: 9 | def __init__(self, answer_id: int): 10 | self.answer_id = answer_id 11 | 12 | @Mediator.handler 13 | class GetAnswerQueryHandler: 14 | def __init__(self, answer_repository: AnswerRepository): 15 | self.answer_repository = answer_repository 16 | 17 | def handle(self, query: GetAnswerQuery) -> Answer: 18 | logger.info(f"Getting answer {query.answer_id}") 19 | answer = self.answer_repository.get(query.answer_id) 20 | if not answer: 21 | raise ValueError(f"Answer with id {query.answer_id} not found") 22 | return answer 23 | -------------------------------------------------------------------------------- /qna_api/features/user/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, EmailStr 2 | 3 | class Token(BaseModel): 4 | access_token: str 5 | token_type: str 6 | 7 | class TokenData(BaseModel): 8 | username: str | None = None 9 | 10 | class User(BaseModel): 11 | id: int | None = None 12 | username: str 13 | email: EmailStr | None = None 14 | full_name: str | None = None 15 | disabled: bool | None = None 16 | 17 | class Config: 18 | from_attributes = True 19 | 20 | 21 | class SignupModel(BaseModel): 22 | username: str 23 | email: EmailStr 24 | full_name: str 25 | password: str 26 | 27 | class Config: 28 | from_attributes = True 29 | 30 | class SignupResponse(BaseModel): 31 | user: User 32 | message: str 33 | 34 | class Config: 35 | from_attributes = True 36 | 37 | class UserUpdate(User): 38 | password: str | None = None 39 | -------------------------------------------------------------------------------- /qna_api/features/questions/commands/delete_question_command.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | from qna_api.features.questions.models import Question 3 | from qna_api.features.questions.repository import QuestionRepository 4 | 5 | class DeleteQuestionCommand: 6 | def __init__(self, question_id: int, user_id: int): 7 | self.question_id = question_id 8 | self.user_id = user_id 9 | 10 | @Mediator.handler 11 | class DeleteQuestionCommandHandler: 12 | def __init__(self): 13 | self.question_repository = QuestionRepository.instance() 14 | 15 | def handle(self, request: DeleteQuestionCommand) -> Question: 16 | db_question = self.question_repository.delete(request.question_id, request.user_id) 17 | if not db_question: 18 | raise ValueError("Question not found or not authorized to delete") 19 | return Question.model_validate(db_question, from_attributes=True) 20 | -------------------------------------------------------------------------------- /qna_api/features/answers/queries/get_question_answers_query.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | from qna_api.features.answers.models import Answer 3 | from qna_api.features.questions.repository import QuestionRepository 4 | from typing import List 5 | from qna_api.crosscutting.logging import get_logger 6 | 7 | logger = get_logger(__name__) 8 | 9 | class GetQuestionAnswersQuery: 10 | def __init__(self, question_id: int): 11 | self.question_id = question_id 12 | 13 | @Mediator.handler 14 | class GetQuestionAnswersQueryHandler: 15 | def __init__(self): 16 | self.question_repository = QuestionRepository.instance() 17 | 18 | def handle(self, request: GetQuestionAnswersQuery) -> List[Answer]: 19 | logger.info(f"Getting answers for question {request.question_id}") 20 | answers = self.question_repository.get_answers(request.question_id) 21 | return [Answer.model_validate(answer, from_attributes=True) for answer in answers] 22 | -------------------------------------------------------------------------------- /qna_api/features/questions/commands/update_question_command.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | from qna_api.features.questions.models import QuestionCreate, Question 3 | from qna_api.features.questions.repository import QuestionRepository 4 | 5 | class UpdateQuestionCommand: 6 | def __init__(self, question_id: int, question: QuestionCreate, user_id: int): 7 | self.question_id = question_id 8 | self.question = question 9 | self.user_id = user_id 10 | 11 | @Mediator.handler 12 | class UpdateQuestionCommandHandler: 13 | def __init__(self): 14 | self.question_repository = QuestionRepository.instance() 15 | 16 | def handle(self, request: UpdateQuestionCommand) -> Question: 17 | db_question = self.question_repository.update(request.question_id, request.question, request.user_id) 18 | if not db_question: 19 | raise ValueError("Question not found or not authorized to update") 20 | return Question.model_validate(db_question, from_attributes=True) 21 | -------------------------------------------------------------------------------- /qna_api/features/questions/commands/create_question_command.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | from qna_api.features.questions.models import QuestionCreate, Question 3 | from qna_api.domain.question import QuestionEntity 4 | from qna_api.features.questions.repository import QuestionRepository 5 | 6 | class CreateQuestionCommand: 7 | def __init__(self, question: QuestionCreate, user_id: int): 8 | self.question = question 9 | self.user_id = user_id 10 | 11 | @Mediator.handler 12 | class CreateQuestionCommandHandler: 13 | def __init__(self): 14 | self.question_repository = QuestionRepository.instance() 15 | 16 | def handle(self, request: CreateQuestionCommand) -> Question: 17 | db_question = QuestionEntity( 18 | title=request.question.title, 19 | description=request.question.description, 20 | user_id=request.user_id 21 | ) 22 | self.question_repository.create(db_question) 23 | return Question.model_validate(db_question, from_attributes=True) 24 | -------------------------------------------------------------------------------- /qna_api/domain/user.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from sqlalchemy import Column, Enum, Integer, String, Boolean 3 | from sqlalchemy.orm import relationship 4 | from qna_api.core.database import Base 5 | from qna_api.domain.role import Role 6 | 7 | class UserEntity(Base): 8 | __tablename__ = "users" 9 | 10 | id = Column(Integer, primary_key=True, index=True) 11 | username = Column(String, unique=True, index=True) 12 | email = Column(String, unique=True, index=True) 13 | full_name = Column(String) 14 | hashed_password = Column(String) 15 | disabled = Column(Boolean, default=False) 16 | roles = Column(String) # Roles stored as comma-separated string 17 | 18 | questions = relationship("QuestionEntity", back_populates="user") 19 | answers = relationship("AnswerEntity", back_populates="user") 20 | 21 | def get_roles(self) -> List[Role]: 22 | return [Role(role) for role in self.roles.split(",")] 23 | 24 | def set_roles(self, roles: List[Role]) -> None: 25 | self.roles = ",".join([role.value for role in roles]) 26 | -------------------------------------------------------------------------------- /qna_api/features/answers/commands/delete_answer_command.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | from qna_api.features.answers.repository import AnswerRepository 3 | from qna_api.features.answers.models import Answer 4 | 5 | from qna_api.crosscutting.logging import get_logger 6 | 7 | logger = get_logger(__name__) 8 | 9 | class DeleteAnswerCommand: 10 | def __init__(self, answer_id: int): 11 | self.answer_id = answer_id 12 | 13 | @Mediator.handler 14 | class DeleteAnswerCommandHandler: 15 | def __init__(self, answer_repository: AnswerRepository): 16 | self.answer_repository = answer_repository 17 | 18 | def handle(self, command: DeleteAnswerCommand) -> Answer: 19 | logger.info(f"Deleting answer {command.answer_id}") 20 | answer = self.answer_repository.get(command.answer_id) 21 | if not answer: 22 | raise ValueError(f"Answer with id {command.answer_id} not found") 23 | 24 | self.answer_repository.delete(command.answer_id) 25 | logger.info(f"Answer {command.answer_id} deleted") 26 | return answer 27 | -------------------------------------------------------------------------------- /qna_api/features/admin/commands/enable_user_command.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | from qna_api.features.user.repository import UserRepository 3 | from qna_api.features.user.models import User 4 | from qna_api.crosscutting.logging import get_logger 5 | 6 | logger = get_logger(__name__) 7 | 8 | class EnableUserCommand: 9 | def __init__(self, user_id: int): 10 | self.user_id = user_id 11 | 12 | @Mediator.handler 13 | class EnableUserCommandHandler: 14 | def __init__(self): 15 | self.user_repository = UserRepository.instance() 16 | 17 | def handle(self, command: EnableUserCommand) -> User: 18 | user = self.user_repository.get(command.user_id) 19 | if not user: 20 | raise ValueError(f"User with id {command.user_id} not found") 21 | if not user.disabled: 22 | raise ValueError(f"User with id {command.user_id} is already enabled") 23 | 24 | user.disabled = False 25 | self.user_repository.update(user) 26 | logger.info(f"User {user.username} has been enabled.") 27 | return User.model_validate(user, from_attributes=True) 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Juan G Carmona 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /qna_api/core/config.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | from pydantic import Field, ValidationError 3 | from pydantic_settings import BaseSettings 4 | from qna_api.crosscutting.logging import get_logger 5 | 6 | load_dotenv() 7 | 8 | logger = get_logger(__name__) 9 | 10 | class Settings(BaseSettings): 11 | database_url: str = Field(..., json_schema_extra={"env": "DATABASE_URL"}) 12 | initial_admin_username: str = Field(..., json_schema_extra={"env": "INITIAL_ADMIN_USERNAME"}) 13 | initial_admin_email: str = Field(..., json_schema_extra={"env": "INITIAL_ADMIN_EMAIL"}) 14 | initial_admin_password: str = Field(..., json_schema_extra={"env": "INITIAL_ADMIN_PASSWORD"}) 15 | secret_key: str = Field(..., json_schema_extra={"env": "SECRET_KEY"}) 16 | algorithm: str = Field(..., json_schema_extra={"env": "ALGORITHM"}) 17 | access_token_expire_minutes: int = Field(..., json_schema_extra={"env": "ACCESS_TOKEN_EXPIRE_MINUTES"}) 18 | 19 | class Config: 20 | env_file = ".env" 21 | 22 | try: 23 | settings = Settings() 24 | except ValidationError as e: 25 | logger.error("Error loading settings: %s", e.json()) 26 | raise 27 | 28 | -------------------------------------------------------------------------------- /qna_api/features/answers/commands/update_answer_command.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | from qna_api.features.answers.repository import AnswerRepository 3 | from qna_api.features.answers.models import AnswerUpdate, Answer 4 | from qna_api.crosscutting.logging import get_logger 5 | 6 | logger = get_logger(__name__) 7 | 8 | class UpdateAnswerCommand: 9 | def __init__(self, answer_id: int, answer: AnswerUpdate): 10 | self.answer_id = answer_id 11 | self.answer = answer 12 | 13 | @Mediator.handler 14 | class UpdateAnswerCommandHandler: 15 | def __init__(self): 16 | self.answer_repository = AnswerRepository.instance() 17 | 18 | def handle(self, command: UpdateAnswerCommand) -> Answer: 19 | logger.info(f"Updating answer {command.answer_id}") 20 | answer = self.answer_repository.get(command.answer_id) 21 | if not answer: 22 | raise ValueError(f"Answer with id {command.answer_id} not found") 23 | 24 | answer.content = command.answer.content 25 | self.answer_repository.update(answer) 26 | logger.info(f"Answer {command.answer_id} updated") 27 | return answer 28 | -------------------------------------------------------------------------------- /qna_api/features/answers/commands/add_answer_command.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | from qna_api.features.answers.models import AnswerCreate, Answer 3 | from qna_api.domain.answer import AnswerEntity 4 | from qna_api.features.questions.repository import QuestionRepository 5 | from qna_api.crosscutting.logging import get_logger 6 | 7 | logger = get_logger(__name__) 8 | 9 | class AddAnswerCommand: 10 | def __init__(self, question_id: int, answer: AnswerCreate, user_id: int): 11 | self.question_id = question_id 12 | self.answer = answer 13 | self.user_id = user_id 14 | 15 | @Mediator.handler 16 | class AddAnswerCommandHandler: 17 | def __init__(self): 18 | self.question_repository = QuestionRepository.instance() 19 | 20 | def handle(self, request: AddAnswerCommand) -> Answer: 21 | logger.info(f"User {request.user_id} is adding an answer to question {request.question_id}") 22 | db_answer = AnswerEntity( 23 | content=request.answer.content, 24 | user_id=request.user_id, 25 | question_id=request.question_id 26 | ) 27 | self.question_repository.add_answer(db_answer) 28 | return Answer.model_validate(db_answer, from_attributes=True) 29 | -------------------------------------------------------------------------------- /qna_api/features/questions/models.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from pydantic import BaseModel, Field, field_validator 3 | from sqlalchemy import Column, Integer, String, ForeignKey 4 | from sqlalchemy.orm import relationship 5 | from qna_api.features.answers.models import Answer 6 | from qna_api.core.database import Base 7 | 8 | class QuestionBase(BaseModel): 9 | title: str = Field(..., example="How to implement authentication in FastAPI?") 10 | description: str = Field(..., example="I need help with implementing authentication using JWT in FastAPI.") 11 | 12 | @field_validator('description') 13 | def validate_description(cls, v): 14 | if len(v) < 10: 15 | raise ValueError('Description must be at least 10 characters long') 16 | if len(v) > 200: 17 | raise ValueError('Description must be less than 200 characters') 18 | return v 19 | 20 | class QuestionCreate(QuestionBase): 21 | pass 22 | 23 | class Question(QuestionBase): 24 | id: int 25 | user_id: int 26 | 27 | class Config: 28 | from_attributes = True 29 | 30 | class FullQuestion(BaseModel): 31 | id: int 32 | title: str 33 | description: str 34 | user_id: int 35 | answers: List[Answer] 36 | 37 | class Config: 38 | from_attributes = True 39 | -------------------------------------------------------------------------------- /qna_api/features/vote/commands/add_answer_vote_command.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | from qna_api.domain.vote import VoteEntity 3 | from qna_api.features.vote.models import VoteCreate, Vote 4 | from qna_api.features.vote.repository import VoteRepository 5 | from qna_api.features.answers.repository import AnswerRepository 6 | 7 | class AddAnswerVoteCommand: 8 | def __init__(self, vote: VoteCreate, user_id: int, answer_id: int): 9 | self.vote = vote 10 | self.user_id = user_id 11 | self.answer_id = answer_id 12 | 13 | @Mediator.handler 14 | class AddAnswerVoteCommandHandler: 15 | def __init__(self): 16 | self.vote_repository = VoteRepository.instance() 17 | self.answer_repository = AnswerRepository.instance() 18 | 19 | def handle(self, command: AddAnswerVoteCommand) -> Vote: 20 | vote_entity = VoteEntity( 21 | user_id=command.user_id, 22 | answer_id=command.answer_id, 23 | vote_value=command.vote.vote_value 24 | ) 25 | self.vote_repository.create(vote_entity) 26 | 27 | answer = self.answer_repository.get(command.answer_id) 28 | answer.score += command.vote.vote_value 29 | self.answer_repository.update(answer) 30 | 31 | return Vote.model_validate(vote_entity, from_attributes=True) -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | import pytest 3 | from fastapi.testclient import TestClient 4 | from qna_api.core.config import settings 5 | from qna_api.core.database import get_db, init_db 6 | 7 | @pytest.fixture(scope="session", autouse=True) 8 | def load_settings(): 9 | # Set up the settings for the tests 10 | settings.secret_key = "your_secret_key" 11 | settings.algorithm = "HS256" 12 | settings.access_token_expire_minutes = 30 13 | settings.database_url = "sqlite:///./test_qna.db" 14 | settings.initial_admin_username = "admin" 15 | settings.initial_admin_email = "admin@example.com" 16 | settings.initial_admin_password = "P@ssw0rd!" 17 | 18 | 19 | # Mock init_db to avoid its execution 20 | # monkeypatch.setattr('qna_api.core.database.init_db', lambda: None) 21 | yield 22 | 23 | @pytest.fixture(scope='module') 24 | def mock_db_session(): 25 | # Create a mock database session 26 | session = MagicMock() 27 | yield session 28 | 29 | @pytest.fixture 30 | def client(mock_db_session): 31 | from qna_api.main import app 32 | def get_db_override(): 33 | return mock_db_session 34 | 35 | app.dependency_overrides[get_db] = get_db_override 36 | client = TestClient(app) 37 | yield client 38 | app.dependency_overrides.clear() 39 | -------------------------------------------------------------------------------- /qna_api/features/vote/commands/add_question_vote_command.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | from qna_api.domain.vote import VoteEntity 3 | from qna_api.features.vote.models import VoteCreate, Vote 4 | from qna_api.features.vote.repository import VoteRepository 5 | from qna_api.features.questions.repository import QuestionRepository 6 | 7 | class AddQuestionVoteCommand: 8 | def __init__(self, vote: VoteCreate, user_id: int, question_id: int): 9 | self.vote = vote 10 | self.user_id = user_id 11 | self.question_id = question_id 12 | 13 | @Mediator.handler 14 | class AddQuestionVoteCommandHandler: 15 | def __init__(self): 16 | self.vote_repository = VoteRepository.instance() 17 | self.question_repository = QuestionRepository.instance() 18 | 19 | def handle(self, command: AddQuestionVoteCommand) -> Vote: 20 | vote_entity = VoteEntity( 21 | user_id=command.user_id, 22 | question_id=command.question_id, 23 | vote_value=command.vote.vote_value 24 | ) 25 | self.vote_repository.create(vote_entity) 26 | 27 | question = self.question_repository.get(command.question_id) 28 | question.score += command.vote.vote_value 29 | self.question_repository.update(question) 30 | 31 | return Vote.model_validate(vote_entity, from_attributes=True) -------------------------------------------------------------------------------- /qna_api/features/user/commands/validate_user_command.py: -------------------------------------------------------------------------------- 1 | 2 | from mediatr import Mediator 3 | from fastapi import HTTPException, status 4 | from qna_api.features.auth.auth_service import AuthService 5 | from qna_api.features.user.repository import UserRepository 6 | from qna_api.features.user.models import User 7 | 8 | class ValidateUserCommand: 9 | def __init__(self, token: str): 10 | self.token = token 11 | 12 | @Mediator.handler 13 | class ValidateUserCommandHandler: 14 | def __init__(self): 15 | self.user_repository = UserRepository.instance() 16 | self.auth_service = AuthService(self.user_repository) 17 | 18 | def handle(self, request: ValidateUserCommand) -> User: 19 | try: 20 | payload = self.auth_service.decode_verification_token(request.token) 21 | user_id = payload.get("user_id") 22 | if not user_id: 23 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid token") 24 | user = self.user_repository.get(user_id) 25 | if not user: 26 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") 27 | user.disabled = False 28 | self.user_repository.update(user) 29 | return user 30 | except Exception as e: 31 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.unittestEnabled": false, 3 | "python.testing.pytestEnabled": true, 4 | "python.testing.pytestArgs": [ 5 | "-v", 6 | "--cov=.", 7 | "--cov-report=xml:coverage.xml", 8 | "--cov-config=tox.ini" 9 | ], 10 | "files.exclude": { 11 | "**/__pycache__": true, 12 | "**/.pytest_cache": true 13 | }, 14 | "workbench.colorCustomizations": { 15 | "activityBar.activeBackground": "#65c89b", 16 | "activityBar.background": "#65c89b", 17 | "activityBar.foreground": "#15202b", 18 | "activityBar.inactiveForeground": "#15202b99", 19 | "activityBarBadge.background": "#945bc4", 20 | "activityBarBadge.foreground": "#e7e7e7", 21 | "commandCenter.border": "#15202b99", 22 | "sash.hoverBorder": "#65c89b", 23 | "statusBar.background": "#42b883", 24 | "statusBar.foreground": "#15202b", 25 | "statusBarItem.hoverBackground": "#359268", 26 | "statusBarItem.remoteBackground": "#42b883", 27 | "statusBarItem.remoteForeground": "#15202b", 28 | "titleBar.activeBackground": "#42b883", 29 | "titleBar.activeForeground": "#15202b", 30 | "titleBar.inactiveBackground": "#42b88399", 31 | "titleBar.inactiveForeground": "#15202b99" 32 | }, 33 | "peacock.remoteColor": "#42b883", 34 | "livePreview.defaultPreviewPath": "/htmlcov/index.html" 35 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "QnA API (Local)", 6 | "type": "debugpy", 7 | "request": "launch", 8 | "program": "${workspaceFolder}/qna_api/main.py", 9 | "console": "integratedTerminal", 10 | "env": { 11 | "PYTHONUNBUFFERED": "1", 12 | "PYTHONPATH": "${workspaceFolder}" 13 | }, 14 | "args": [ 15 | "run" 16 | ] 17 | }, 18 | { 19 | "name": "pytest QnA API", 20 | "type": "debugpy", 21 | "request": "launch", 22 | "purpose": [ 23 | "debug-test" 24 | ], 25 | "env": { 26 | "PYTEST_ADDOPTS": "--no-cov" 27 | }, 28 | "module": "pytest", 29 | "cwd": "${workspaceRoot}/tests", 30 | "envFile": "${workspaceFolder}/tests/.env", 31 | "console": "integratedTerminal" 32 | }, 33 | { 34 | "name": "QnA API (Remote Attach)", 35 | "type": "debugpy", 36 | "request": "attach", 37 | "connect": { 38 | "host": "localhost", 39 | "port": 5678 40 | }, 41 | "pathMappings": [ 42 | { 43 | "localRoot": "${workspaceFolder}", 44 | "remoteRoot": "/app" 45 | } 46 | ] 47 | } 48 | ] 49 | } -------------------------------------------------------------------------------- /qna_api/features/questions/repository.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session, joinedload 2 | from qna_api.core.base_repository import BaseRepository 3 | from qna_api.core.database import get_db 4 | from qna_api.domain.question import QuestionEntity 5 | from qna_api.domain.answer import AnswerEntity 6 | from typing import List 7 | 8 | class QuestionRepository(BaseRepository[QuestionEntity]): 9 | def __init__(self, db: Session): 10 | super().__init__(QuestionEntity, db) 11 | 12 | @classmethod 13 | def instance(cls): 14 | if cls._instance is None: 15 | db = next(get_db()) 16 | cls._instance = cls(db) 17 | return cls._instance 18 | 19 | def get_full_question(self, question_id: int) -> QuestionEntity: 20 | db_question = ( 21 | self.db.query(QuestionEntity) 22 | .options( 23 | joinedload(QuestionEntity.answers), 24 | joinedload(QuestionEntity.user), 25 | joinedload(QuestionEntity.answers).joinedload(AnswerEntity.user) 26 | ) 27 | .filter(QuestionEntity.id == question_id) 28 | .first() 29 | ) 30 | return db_question 31 | 32 | def add_answer(self, answer: AnswerEntity) -> AnswerEntity: 33 | self.db.add(answer) 34 | self.db.commit() 35 | self.db.refresh(answer) 36 | return answer 37 | 38 | def get_answers(self, question_id: int) -> List[AnswerEntity]: 39 | return self.db.query(AnswerEntity).filter(AnswerEntity.question_id == question_id).all() -------------------------------------------------------------------------------- /qna_api/features/auth/controller.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException, status 2 | from fastapi.security import OAuth2PasswordRequestForm 3 | from qna_api.features.auth.models import Token 4 | from qna_api.features.auth.auth_service import AuthService 5 | from qna_api.crosscutting.logging import get_logger 6 | from datetime import timedelta 7 | from qna_api.core.config import settings 8 | 9 | logger = get_logger(__name__) 10 | 11 | class AuthController: 12 | def __init__(self, auth_service: AuthService): 13 | self.auth_service = auth_service 14 | self.router = APIRouter() 15 | self._add_routes() 16 | 17 | def _add_routes(self): 18 | self.router.post("/token", response_model=Token)(self.authenticate) 19 | 20 | async def authenticate(self, form_data: OAuth2PasswordRequestForm = Depends()): 21 | logger.info(f"Logging in user: {form_data.username}") 22 | user = self.auth_service.authenticate_user(form_data.username, form_data.password) 23 | if not user: 24 | raise HTTPException( 25 | status_code=status.HTTP_401_UNAUTHORIZED, 26 | detail="Incorrect username or password", 27 | headers={"WWW-Authenticate": "Bearer"}, 28 | ) 29 | access_token_expires = timedelta(minutes=settings.access_token_expire_minutes) 30 | access_token = self.auth_service.create_access_token( 31 | data={"sub": user.username,"roles": user.roles}, 32 | expires_delta=access_token_expires) 33 | return {"access_token": access_token, "token_type": "bearer"} 34 | -------------------------------------------------------------------------------- /qna_api/core/database.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.orm import sessionmaker, declarative_base 3 | from qna_api.core.config import settings 4 | from passlib.context import CryptContext 5 | 6 | from qna_api.domain.role import Role 7 | 8 | SQLALCHEMY_DATABASE_URL = settings.database_url 9 | 10 | engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) 11 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 12 | Base = declarative_base() 13 | 14 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 15 | 16 | def get_db(): 17 | db = SessionLocal() 18 | try: 19 | yield db 20 | finally: 21 | db.close() 22 | 23 | def init_db(): 24 | from qna_api.domain.user import UserEntity 25 | from qna_api.domain.question import QuestionEntity 26 | from qna_api.domain.answer import AnswerEntity 27 | from qna_api.domain.vote import VoteEntity 28 | from qna_api.features.auth.auth_service import AuthService 29 | from qna_api.features.user.repository import UserRepository 30 | 31 | Base.metadata.create_all(bind=engine) 32 | 33 | # Create an initial admin user if none exists 34 | db = SessionLocal() 35 | user_repo = UserRepository(db) 36 | 37 | if not user_repo.get_by_username(settings.initial_admin_username): 38 | admin_user = UserEntity( 39 | username=settings.initial_admin_username, 40 | email=settings.initial_admin_email, 41 | full_name="API Admin", 42 | hashed_password=pwd_context.hash(settings.initial_admin_password), 43 | disabled=False 44 | ) 45 | admin_user.set_roles([Role.ADMIN, Role.USER]) 46 | user_repo.create(admin_user) 47 | db.close() 48 | -------------------------------------------------------------------------------- /tests/features/user/test_repository.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import MagicMock, patch 3 | from sqlalchemy.orm import Session 4 | from qna_api.domain.question import QuestionEntity 5 | from qna_api.domain.answer import AnswerEntity 6 | from qna_api.domain.user import UserEntity 7 | from qna_api.features.user.repository import UserRepository 8 | 9 | # Mock data 10 | mock_answer = AnswerEntity(id=1, content="This is an answer", question_id=1, user_id=1) 11 | mock_question = QuestionEntity(id=1, title="Sample Question", description="This is a sample question", user_id=1, answers=[mock_answer]) 12 | mock_user = UserEntity(id=1, username="testuser", full_name="Test User", email="test@example.com", disabled=False, roles=["user"], questions=[mock_question], answers=[mock_answer]) 13 | 14 | @pytest.fixture 15 | def db_session(): 16 | return MagicMock(spec=Session) 17 | 18 | @pytest.fixture 19 | def user_repository(db_session): 20 | return UserRepository(db=db_session) 21 | 22 | def test_instance_method(db_session): 23 | with patch('qna_api.features.user.repository.get_db', return_value=iter([db_session])): 24 | # Reset the singleton instance for other tests 25 | UserRepository._instance = None 26 | instance1 = UserRepository.instance() 27 | instance2 = UserRepository.instance() 28 | 29 | assert instance1 is instance2 30 | assert isinstance(instance1, UserRepository) 31 | assert instance1.db == db_session 32 | 33 | # Reset the singleton instance for other tests 34 | UserRepository._instance = None 35 | 36 | def test_get_by_username(user_repository, db_session): 37 | db_session.query().filter().first.return_value = mock_user 38 | 39 | result = user_repository.get_by_username("testuser") 40 | 41 | assert result == mock_user -------------------------------------------------------------------------------- /qna_api/core/base_repository.py: -------------------------------------------------------------------------------- 1 | from typing import Type, TypeVar, Generic, List 2 | from sqlalchemy.orm import Session 3 | from sqlalchemy.exc import IntegrityError 4 | 5 | from qna_api.core.database import get_db 6 | 7 | T = TypeVar('T') 8 | 9 | class BaseRepository(Generic[T]): 10 | _instance = None 11 | 12 | def __init__(self, model: Type[T], db: Session): 13 | self.model = model 14 | self.db = db 15 | 16 | def get(self, id: int) -> T: 17 | return self.db.query(self.model).get(id) 18 | 19 | def get_all(self) -> List[T]: 20 | return self.db.query(self.model).all() 21 | 22 | def create(self, obj: T) -> T: 23 | try: 24 | self.db.add(obj) 25 | self.db.commit() 26 | self.db.refresh(obj) 27 | return obj 28 | except IntegrityError as e: 29 | self.db.rollback() 30 | self.refresh_db() 31 | raise ValueError(self._parse_integrity_error(e)) 32 | 33 | def update(self, obj: T) -> T: 34 | try: 35 | self.db.merge(obj) 36 | self.db.commit() 37 | return obj 38 | except IntegrityError as e: 39 | self.db.rollback() 40 | self.refresh_db() 41 | raise ValueError(self._parse_integrity_error(e)) 42 | 43 | def delete(self, id: int) -> None: 44 | obj = self.get(id) 45 | self.db.delete(obj) 46 | self.db.commit() 47 | 48 | def refresh_db(self): 49 | self.db = next(get_db()) 50 | 51 | def _parse_integrity_error(self, error: IntegrityError) -> str: 52 | orig_msg = str(error.orig) 53 | err_msg = orig_msg.split(':')[-1].replace('\n', '').strip() 54 | 55 | parts = err_msg.split('.') 56 | if len(parts) >= 2: 57 | table, column = parts[-2], parts[-1] 58 | return f"Duplicate entry for {column} in {table}. Please choose a different value." 59 | else: 60 | return "An error occurred while processing your request." 61 | -------------------------------------------------------------------------------- /qna_api/core/constants.py: -------------------------------------------------------------------------------- 1 | # constants.py 2 | APP_NAME = "qna_api" 3 | TITLE = "QnA API" 4 | DESCRIPTION = """ 5 | Welcome to the Custom QA REST API documentation! 6 | 7 | This API provides endpoints for managing questions, answers, and comments in a Q&A system. You can use this API to create, retrieve, update, and delete records. The main features include: 8 | 9 | - **Questions Management**: Create a question, retrieve a list of questions, get details of a specific question by its ID, and delete questions. 10 | - **Answers Management**: Post an answer to a specific question, retrieve all answers for a given question, get details of a specific answer, and delete answers. 11 | - **Comments Management**: Add comments to answers, retrieve all comments for a given answer, and delete comments. 12 | 13 | ### Authentication 14 | Currently, this API does not require authentication. In a production environment, you would typically secure these endpoints with authentication and authorization mechanisms. 15 | 16 | ### Error Handling 17 | The API provides meaningful error messages and HTTP status codes to help you understand what went wrong. Common status codes include: 18 | - **200 OK**: The request was successful. 19 | - **201 Created**: A new resource was successfully created. 20 | - **400 Bad Request**: The request was invalid or cannot be otherwise served. 21 | - **404 Not Found**: The requested resource could not be found. 22 | - **422 Unprocessable Entity**: The request was well-formed but was unable to be followed due to semantic errors, such as validation errors in the request body. 23 | - **500 Internal Server Error**: An error occurred on the server. 24 | 25 | Explore the endpoints below to see how you can integrate our API into your application. Happy coding! 26 | """ 27 | CONTACT = { 28 | "name": "Juan G Carmona", 29 | "url": "http://jgcarmona.com/contact", 30 | "email": "juan@jgcarmona.com", 31 | } 32 | LICENSE_INFO = { 33 | "name": "MIT", 34 | "url": "https://opensource.org/licenses/MIT", 35 | } 36 | SWAGGER_UI_PARAMETERS = {"syntaxHighlight.theme": "obsidian"} 37 | SWAGGER_FAVICON_URL = "https://example.com/your-favicon.ico" 38 | -------------------------------------------------------------------------------- /qna_api/crosscutting/authorization.py: -------------------------------------------------------------------------------- 1 | 2 | from fastapi import Depends, HTTPException, status 3 | from fastapi.security import OAuth2PasswordBearer 4 | from jose import JWTError 5 | from jose import JWTError, jwt 6 | from qna_api.features.auth.models import TokenData 7 | from qna_api.core.config import settings 8 | from qna_api.features.user.repository import UserRepository 9 | 10 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") 11 | 12 | def get_authenticated_user(token: str = Depends(oauth2_scheme), user_repo: UserRepository = Depends(UserRepository.instance)): 13 | credentials_exception = HTTPException( 14 | status_code=status.HTTP_401_UNAUTHORIZED, 15 | detail="Could not validate credentials", 16 | headers={"WWW-Authenticate": "Bearer"}, 17 | ) 18 | token_exception = HTTPException( 19 | status_code=status.HTTP_400_BAD_REQUEST, 20 | detail="Invalid token", 21 | headers={"WWW-Authenticate": "Bearer"}, 22 | ) 23 | 24 | account_disabled_exception = HTTPException( 25 | status_code=status.HTTP_403_FORBIDDEN, 26 | detail="Your account is disabled. Please contact support.", 27 | ) 28 | try: 29 | payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) 30 | username: str = payload.get("sub") 31 | if not username: 32 | raise token_exception 33 | token_data = TokenData(username=username, roles=payload.get("roles")) 34 | except JWTError: 35 | raise token_exception 36 | 37 | user = user_repo.get_by_username(token_data.username) 38 | if user is None: 39 | raise credentials_exception 40 | if user.disabled: 41 | raise account_disabled_exception 42 | return user 43 | 44 | def get_admin_user(token: str = Depends(oauth2_scheme), user_repo: UserRepository = Depends(UserRepository.instance)): 45 | user = get_authenticated_user(token, user_repo) 46 | if 'admin' not in user.roles: 47 | raise HTTPException( 48 | status_code=status.HTTP_403_FORBIDDEN, 49 | detail="You do not have the necessary permissions", 50 | ) 51 | return user -------------------------------------------------------------------------------- /qna_api/features/vote/controller.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException, status 2 | from qna_api.features.vote.commands.add_answer_vote_command import AddAnswerVoteCommand 3 | from qna_api.features.vote.commands.add_question_vote_command import AddQuestionVoteCommand 4 | from qna_api.features.vote.models import VoteCreate, Vote 5 | from qna_api.crosscutting.authorization import get_authenticated_user 6 | from qna_api.crosscutting.logging import get_logger 7 | from qna_api.features.user.models import User 8 | from mediatr import Mediator 9 | 10 | logger = get_logger(__name__) 11 | 12 | class VotesController: 13 | def __init__(self, mediator: Mediator): 14 | self.mediator = mediator 15 | self.router = APIRouter() 16 | self._add_routes() 17 | 18 | def _add_routes(self): 19 | self.router.post("/question/{question_id}/vote", response_model=Vote)(self.vote_on_question) 20 | self.router.post("/question/{question_id}/answer/{answer_id}/vote", response_model=Vote)(self.vote_on_answer) 21 | 22 | async def vote_on_question(self, question_id: int, vote: VoteCreate, current_user: User = Depends(get_authenticated_user)): 23 | logger.info(f"{current_user.full_name} is casting a vote on question {question_id}") 24 | try: 25 | response = await self.mediator.send_async(AddQuestionVoteCommand(vote, current_user.id, question_id)) 26 | return response 27 | except ValueError as e: 28 | logger.error(f"Error casting vote: {str(e)}") 29 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 30 | 31 | async def vote_on_answer(self, answer_id: int, vote: VoteCreate, current_user: User = Depends(get_authenticated_user)): 32 | logger.info(f"{current_user.full_name} is casting a vote on answer {answer_id}") 33 | try: 34 | response = await self.mediator.send_async(AddAnswerVoteCommand(vote, current_user.id, answer_id)) 35 | return response 36 | except ValueError as e: 37 | logger.error(f"Error casting vote: {str(e)}") 38 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 39 | -------------------------------------------------------------------------------- /qna_api/features/user/commands/signup_command.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | from qna_api.crosscutting.notification_service import NotificationService 3 | from qna_api.features.auth.auth_service import AuthService 4 | from qna_api.crosscutting.logging import get_logger 5 | from qna_api.domain.role import Role 6 | from qna_api.domain.user import UserEntity 7 | from qna_api.features.user.models import SignupModel, SignupResponse, User 8 | from qna_api.features.user.repository import UserRepository 9 | 10 | logger = get_logger(__name__) 11 | 12 | class SignupCommand(): 13 | def __init__(self, user: SignupModel): 14 | self.user = user 15 | 16 | @Mediator.handler 17 | class SignupCommandHandler(): 18 | def __init__(self): 19 | self.user_repository = UserRepository.instance() 20 | self.notification_service = NotificationService.from_env_vars() 21 | self.auth_service = AuthService(self.user_repository) 22 | 23 | async def handle(self, request: SignupCommand) -> SignupResponse: 24 | logger.info(f"Creating user: {request.user.username}") 25 | user = UserEntity( 26 | username=request.user.username, 27 | email=request.user.email, 28 | full_name=request.user.full_name, 29 | hashed_password=AuthService.get_password_hash(request.user.password), 30 | disabled=True 31 | ) 32 | user.set_roles([Role.USER]) 33 | 34 | try: 35 | self.user_repository.create(user) 36 | verification_token = self.auth_service.create_verification_token(user.id) 37 | verification_url = f"http://localhost:8000/user/validate?token={verification_token}" 38 | email_sent = await self.notification_service.send_email_verification(user.email, verification_url) 39 | 40 | if email_sent: 41 | message = "A verification email has been sent. Please check your inbox." 42 | else: 43 | message = "Your account requires admin validation." 44 | 45 | return SignupResponse( 46 | user=User.model_validate(user, from_attributes=True), 47 | message=message 48 | ) 49 | 50 | except ValueError as e: 51 | raise ValueError(f"Error creating user: {str(e)}") 52 | -------------------------------------------------------------------------------- /qna_api/features/admin/controller.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException, status 2 | from mediatr import Mediator 3 | from qna_api.crosscutting.authorization import get_admin_user 4 | from qna_api.features.admin.commands.delete_user_command import DeleteUserCommand 5 | from qna_api.features.user.models import User 6 | from qna_api.features.admin.commands.enable_user_command import EnableUserCommand 7 | from qna_api.features.admin.queries.get_all_users_query import GetAllUsersQuery 8 | from qna_api.crosscutting.logging import get_logger 9 | from qna_api.features.user.models import User 10 | 11 | logger = get_logger(__name__) 12 | 13 | class AdminController: 14 | def __init__(self, mediator: Mediator): 15 | self.mediator = mediator 16 | self.router = APIRouter() 17 | self.router = APIRouter(dependencies=[Depends(get_admin_user)]) 18 | self._add_routes() 19 | 20 | def _add_routes(self): 21 | self.router.get("/users", response_model=list[User])(self.get_all_users) 22 | self.router.put("/users/{user_id}/enable", response_model=User)(self.enable_user) 23 | self.router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)(self.delete_user) 24 | 25 | async def get_all_users(self): 26 | logger.info(f"an admin user is retrieving all users") 27 | try: 28 | users = await self.mediator.send_async(GetAllUsersQuery()) 29 | return users 30 | except ValueError as e: 31 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 32 | 33 | async def enable_user(self, user_id: int): 34 | logger.info(f"an admin user is retrieving all users") 35 | try: 36 | enabled_user = await self.mediator.send_async(EnableUserCommand(user_id)) 37 | return enabled_user 38 | except ValueError as e: 39 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 40 | 41 | async def delete_user(self, user_id: int): 42 | logger.info(f"An admin user is deleting user with id {user_id}") 43 | try: 44 | await self.mediator.send_async(DeleteUserCommand(user_id)) 45 | except ValueError as e: 46 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 47 | -------------------------------------------------------------------------------- /tests/features/questions/test_repository.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import MagicMock, patch 3 | from sqlalchemy.orm import Session 4 | from qna_api.domain.question import QuestionEntity 5 | from qna_api.domain.answer import AnswerEntity 6 | from qna_api.domain.user import UserEntity 7 | from qna_api.features.questions.repository import QuestionRepository 8 | 9 | # Mock data 10 | mock_user = UserEntity(id=1, username="testuser", full_name="Test User", email="test@example.com", disabled=False, roles=["user"], questions=[], answers=[]) 11 | mock_answer = AnswerEntity(id=1, content="This is an answer", question_id=1, user_id=1) 12 | mock_question = QuestionEntity(id=1, title="Sample Question", description="This is a sample question", user_id=1, 13 | user=mock_user, answers=[mock_answer]) 14 | 15 | @pytest.fixture 16 | def db_session(): 17 | return MagicMock(spec=Session) 18 | 19 | @pytest.fixture 20 | def question_repository(db_session): 21 | return QuestionRepository(db=db_session) 22 | 23 | def test_instance_method(db_session): 24 | with patch('qna_api.features.questions.repository.get_db', return_value=iter([db_session])): 25 | instance1 = QuestionRepository.instance() 26 | instance2 = QuestionRepository.instance() 27 | 28 | assert instance1 is instance2 29 | assert isinstance(instance1, QuestionRepository) 30 | assert instance1.db == db_session 31 | 32 | # Reset the singleton instance for other tests 33 | QuestionRepository._instance = None 34 | 35 | def test_get_full_question(question_repository, db_session): 36 | query_mock = db_session.query().options().filter() 37 | query_mock.first.return_value = mock_question 38 | 39 | result = question_repository.get_full_question(1) 40 | 41 | assert result == mock_question 42 | 43 | def test_add_answer(question_repository, db_session): 44 | db_session.add.return_value = None 45 | db_session.commit.return_value = None 46 | db_session.refresh.return_value = None 47 | 48 | result = question_repository.add_answer(mock_answer) 49 | 50 | assert result == mock_answer 51 | 52 | def test_get_answers(question_repository, db_session): 53 | db_session.query().filter().all.return_value = [mock_answer] 54 | 55 | result = question_repository.get_answers(1) 56 | 57 | assert result == [mock_answer] 58 | -------------------------------------------------------------------------------- /qna_api/features/auth/auth_service.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | from fastapi import HTTPException, status 3 | from jose import ExpiredSignatureError, JWTError, jwt 4 | from passlib.context import CryptContext 5 | from qna_api.core.config import settings 6 | from qna_api.domain.user import UserEntity 7 | from qna_api.features.user.repository import UserRepository 8 | 9 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 10 | 11 | class AuthService: 12 | def __init__(self, user_repo: UserRepository): 13 | self.user_repo = user_repo 14 | 15 | def authenticate_user(self, username: str, password: str) -> UserEntity | None: 16 | user = self.user_repo.get_by_username(username) 17 | if not user or not self._verify_password(password, user.hashed_password): 18 | return None 19 | return user 20 | 21 | def create_access_token(self, data: dict, expires_delta: timedelta | None = None): 22 | to_encode = data.copy() 23 | if expires_delta: 24 | expire = datetime.now(timezone.utc) + expires_delta 25 | else: 26 | expire = datetime.now(timezone.utc) + timedelta(minutes=15) 27 | to_encode.update({"exp": expire}) 28 | encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) 29 | return encoded_jwt 30 | 31 | def create_verification_token(self, user_id: int, expires_delta: timedelta | None = None): 32 | expire = datetime.now(timezone.utc) + (expires_delta or timedelta(hours=24)) 33 | to_encode = {"user_id": user_id, "exp": expire} 34 | return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) 35 | 36 | def decode_verification_token(self, token: str): 37 | try: 38 | payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) 39 | return payload 40 | except ExpiredSignatureError: 41 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Token has expired") 42 | except JWTError: 43 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid token") 44 | 45 | 46 | @staticmethod 47 | def get_password_hash(password: str) -> str: 48 | return pwd_context.hash(password) 49 | 50 | def _verify_password(self, plain_password: str, hashed_password: str) -> bool: 51 | return pwd_context.verify(plain_password, hashed_password) 52 | 53 | -------------------------------------------------------------------------------- /qna_api/crosscutting/notification_service.py: -------------------------------------------------------------------------------- 1 | import os 2 | import smtplib 3 | from email.mime.multipart import MIMEMultipart 4 | from email.mime.text import MIMEText 5 | from qna_api.crosscutting.logging import get_logger 6 | 7 | logger = get_logger(__name__) 8 | 9 | class NotificationService: 10 | def __init__(self, smtp_server: str, smtp_port: int, smtp_username: str, smtp_password: str, sender_email: str): 11 | self.smtp_server = smtp_server 12 | self.smtp_port = smtp_port 13 | self.smtp_username = smtp_username 14 | self.smtp_password = smtp_password 15 | self.sender_email = sender_email 16 | 17 | @classmethod 18 | def from_env_vars(cls): 19 | smtp_server = os.getenv('SMTP_SERVER', 'smtp.gmail.com') 20 | smtp_port = int(os.getenv('SMTP_PORT', 465)) 21 | smtp_username = os.getenv('SMTP_USERNAME') 22 | smtp_password = os.getenv('SMTP_PASSWORD') 23 | sender_email = os.getenv('SENDER_EMAIL', smtp_username) 24 | return cls(smtp_server, smtp_port, smtp_username, smtp_password, sender_email) 25 | 26 | async def send_email_verification(self, recipient_email: str, verification_url: str): 27 | use_smtp = os.getenv('USE_SMTP', 'false').lower() == 'true' 28 | if not use_smtp: 29 | logger.warn("SMTP not configured. Users require admin validation.") 30 | return False # Indicate that email was not sent 31 | 32 | logger.info(f"Sending email verification to {recipient_email} with URL: {verification_url}") 33 | message = MIMEMultipart("alternative") 34 | message["Subject"] = "Email Verification" 35 | message["From"] = self.sender_email 36 | message["To"] = recipient_email 37 | 38 | text = f"Please verify your email by clicking on the following link: {verification_url}" 39 | html = f"

Please verify your email by clicking on the following link: Verify Email

" 40 | 41 | part1 = MIMEText(text, "plain") 42 | part2 = MIMEText(html, "html") 43 | 44 | message.attach(part1) 45 | message.attach(part2) 46 | 47 | try: 48 | with smtplib.SMTP_SSL(self.smtp_server, self.smtp_port) as server: 49 | server.login(self.smtp_username, self.smtp_password) 50 | server.sendmail(self.sender_email, recipient_email, message.as_string()) 51 | logger.info(f"Email sent to {recipient_email}") 52 | except Exception as e: 53 | logger.error(f"Failed to send email to {recipient_email}: {e}") 54 | 55 | -------------------------------------------------------------------------------- /tests/features/auth/test_auth_service.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import MagicMock, patch 3 | from datetime import timedelta 4 | from jose import jwt 5 | from qna_api.features.auth.auth_service import AuthService 6 | from qna_api.domain.user import UserEntity 7 | from qna_api.core.config import settings 8 | 9 | class FakeUserRepository(): 10 | _instance = None 11 | def __init__(self): 12 | self.users = { 13 | "testuser": UserEntity( 14 | id=1, 15 | username="testuser", 16 | ) 17 | } 18 | 19 | def get_by_username(self, username: str) -> UserEntity | None: 20 | return self.users.get(username) 21 | 22 | @pytest.fixture 23 | def user_repo(): 24 | return MagicMock(spec=FakeUserRepository) 25 | 26 | @pytest.fixture 27 | def auth_service(user_repo): 28 | return AuthService(user_repo) 29 | 30 | def test_authenticate_user_success(auth_service): 31 | # Mock the password verification process 32 | with patch('passlib.context.CryptContext.verify', return_value=True): 33 | # Create a mock user with the necessary attributes 34 | mock_user = MagicMock(spec=UserEntity) 35 | mock_user.username = "testuser" 36 | auth_service.user_repo.get_by_username.return_value = mock_user 37 | result = auth_service.authenticate_user("testuser", "password") 38 | assert result.username == "testuser" 39 | 40 | def test_authenticate_user_failure(auth_service): 41 | # Simulate a user not found scenario 42 | auth_service.user_repo.get_by_username.return_value = None 43 | result = auth_service.authenticate_user("wronguser", "password") 44 | assert result is None # Ensure authentication fails 45 | 46 | def test_create_access_token(auth_service): 47 | # Test JWT token creation 48 | data = {"sub": "testuser", "roles": "user"} 49 | token = auth_service.create_access_token(data, timedelta(minutes=15)) 50 | decoded = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) 51 | assert decoded["sub"] == "testuser" # Verify token content 52 | assert decoded["roles"] == "user" 53 | 54 | def test_get_password_hash(auth_service): 55 | # Test password hashing 56 | password = "password123" 57 | hashed_password = auth_service.get_password_hash(password) 58 | assert hashed_password != password # Ensure the password is hashed 59 | 60 | def test_verify_password(auth_service): 61 | # Test password verification 62 | password = "password123" 63 | hashed_password = auth_service.get_password_hash(password) 64 | assert auth_service._verify_password(password, hashed_password) # Ensure the password verifies correctly 65 | -------------------------------------------------------------------------------- /qna_api/features/user/controller.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from fastapi import APIRouter, Depends, HTTPException, status 3 | from mediatr import Mediator 4 | from qna_api.crosscutting.authorization import get_authenticated_user 5 | from qna_api.features.user.commands.signup_command import SignupCommand 6 | from qna_api.features.user.commands.update_user_command import UpdateUserCommand 7 | from qna_api.features.user.commands.validate_user_command import ValidateUserCommand 8 | from qna_api.features.user.models import SignupResponse, User, SignupModel, UserUpdate 9 | from qna_api.features.user.constants import ( 10 | SIGNUP_DESCRIPTION, 11 | ME_DESCRIPTION, 12 | UPDATE_USER_DESCRIPTION, 13 | VALIDATE_USER_DESCRIPTION 14 | ) 15 | 16 | class UserController: 17 | def __init__(self, mediator: Mediator): 18 | self.mediator = mediator 19 | self.router = APIRouter() 20 | self._add_routes() 21 | 22 | def _add_routes(self): 23 | self.router.post("/signup", response_model=SignupResponse, description=SIGNUP_DESCRIPTION)(self.signup) 24 | self.router.get("/me", response_model=User, description=ME_DESCRIPTION )(self.me) 25 | self.router.put("/{user_to_update_id}", response_model=User, description=UPDATE_USER_DESCRIPTION)(self.update_user) 26 | self.router.get("/validate", response_model=User, description=VALIDATE_USER_DESCRIPTION)(self.validate_user) 27 | 28 | async def signup(self, user: SignupModel): 29 | try: 30 | result = await self.mediator.send_async(SignupCommand(user)) 31 | return result 32 | except ValueError as e: 33 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 34 | 35 | async def me(self, current_user: User = Depends(get_authenticated_user)): 36 | return current_user 37 | 38 | async def update_user(self, user_to_update_id: int, user_update: UserUpdate, current_user: User = Depends(get_authenticated_user)): 39 | try: 40 | updated_user_entity = await self.mediator.send_async(UpdateUserCommand(current_user.id, user_to_update_id, user_update)) 41 | return User.model_validate(updated_user_entity, from_attributes=True) 42 | except ValueError as e: 43 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 44 | 45 | async def validate_user(self, token: str): 46 | try: 47 | validated_user_entity = await self.mediator.send_async(ValidateUserCommand(token)) 48 | return User.model_validate(validated_user_entity, from_attributes=True) 49 | except ValueError as e: 50 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -------------------------------------------------------------------------------- /tests/features/vote/test_controller.py: -------------------------------------------------------------------------------- 1 | # tests/features/test_controller.py 2 | import pytest 3 | from fastapi.testclient import TestClient 4 | from unittest.mock import MagicMock 5 | from qna_api.main import create_app 6 | from qna_api.crosscutting.authorization import get_authenticated_user 7 | from qna_api.features.user.models import User 8 | from qna_api.features.vote.models import VoteCreate, Vote 9 | from mediatr import Mediator 10 | 11 | # Configure Test Client 12 | @pytest.fixture 13 | def client(mediator, authenticated_user): 14 | app = create_app(mediator=mediator) 15 | 16 | def _get_authenticated_user(): 17 | return authenticated_user 18 | 19 | app.dependency_overrides[get_authenticated_user] = _get_authenticated_user 20 | 21 | with TestClient(app) as client: 22 | yield client 23 | 24 | # Mock mediator 25 | @pytest.fixture 26 | def mediator(): 27 | return MagicMock(spec=Mediator) 28 | 29 | # Mock authenticated user 30 | @pytest.fixture 31 | def authenticated_user(): 32 | return User(id=1, username="testuser", full_name="Test User", email="testuser@example.com", roles=["user"]) 33 | 34 | def test_vote_on_question(client, mediator): 35 | mediator.send_async.return_value = Vote(id=1, vote_value=1, user_id=1, question_id=1, answer_id=None) 36 | 37 | response = client.post("/question/1/vote", json={"vote_value": 1}) 38 | 39 | assert response.status_code == 200 40 | data = response.json() 41 | assert data["vote_value"] == 1 42 | assert data["question_id"] == 1 43 | mediator.send_async.assert_called_once() 44 | 45 | def test_vote_on_question_value_error(client, mediator): 46 | mediator.send_async.side_effect = ValueError("Test error") 47 | 48 | response = client.post("/question/1/vote", json={"vote_value": 1}) 49 | 50 | assert response.status_code == 400 51 | assert response.json() == {"detail": "Test error"} 52 | mediator.send_async.assert_called_once() 53 | 54 | def test_vote_on_answer(client, mediator): 55 | mediator.send_async.return_value = Vote(id=1, vote_value=1, user_id=1, question_id=None, answer_id=1) 56 | 57 | response = client.post("/question/1/answer/1/vote", json={"vote_value": 1}) 58 | 59 | assert response.status_code == 200 60 | data = response.json() 61 | assert data["vote_value"] == 1 62 | assert data["answer_id"] == 1 63 | mediator.send_async.assert_called_once() 64 | 65 | def test_vote_on_answer_value_error(client, mediator): 66 | mediator.send_async.side_effect = ValueError("Test error") 67 | 68 | response = client.post("/question/1/answer/1/vote", json={"vote_value": 1}) 69 | 70 | assert response.status_code == 400 71 | assert response.json() == {"detail": "Test error"} 72 | mediator.send_async.assert_called_once() 73 | -------------------------------------------------------------------------------- /qna_api/features/user/commands/update_user_command.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | from qna_api.features.auth.auth_service import AuthService 3 | from qna_api.crosscutting.logging import get_logger 4 | from qna_api.domain.role import Role 5 | from qna_api.domain.user import UserEntity 6 | from qna_api.features.user.models import UserUpdate 7 | from qna_api.features.user.repository import UserRepository 8 | 9 | logger = get_logger(__name__) 10 | 11 | class UpdateUserCommand(): 12 | def __init__(self, current_user_id:str, user_to_update_id:str, user_updates: UserUpdate): 13 | self.current_user_id = current_user_id 14 | self.user_to_update_id = user_to_update_id 15 | self.user_updates = user_updates 16 | 17 | @Mediator.handler 18 | class UpdateUserCommandHandler(): 19 | def __init__(self): 20 | self.user_repository = UserRepository.instance() 21 | 22 | def handle(self, request: UpdateUserCommand) -> UserEntity: 23 | logger.info(f"Updating user: {request.user_to_update_id}") 24 | current_user = self.user_repository.get(request.current_user_id) 25 | 26 | if not current_user: 27 | logger.error("Current user not found") 28 | raise ValueError("Current user not found") 29 | if request.user_to_update_id != current_user.id and 'admin' not in [role.value for role in current_user.get_roles()]: 30 | logger.error("Not authorized to update this user") 31 | raise ValueError("Not authorized to update this user") 32 | 33 | user_to_update = self.user_repository.get(request.user_to_update_id) 34 | if not user_to_update: 35 | logger.error("User to update not found") 36 | raise ValueError("User to update not found") 37 | 38 | if request.user_updates.username and request.user_updates.username != user_to_update.username: 39 | user_to_update.username = request.user_updates.username 40 | if request.user_updates.email and request.user_updates.email != user_to_update.email: 41 | user_to_update.email = request.user_updates.email 42 | if request.user_updates.full_name and request.user_updates.full_name != user_to_update.full_name: 43 | user_to_update.full_name = request.user_updates.full_name 44 | if request.user_updates.disabled is not None and request.user_updates.disabled != user_to_update.disabled: 45 | user_to_update.disabled = request.user_updates.disabled 46 | if request.user_updates.password: 47 | new_hashed_password = AuthService.get_password_hash(request.user_updates.password) 48 | if new_hashed_password != user_to_update.hashed_password: 49 | user_to_update.hashed_password = new_hashed_password 50 | 51 | try: 52 | return self.user_repository.update(user_to_update) 53 | except ValueError as e: 54 | logger.error(f"Error updating user: {str(e)}") 55 | raise ValueError(f"Error updating user: {str(e)}") -------------------------------------------------------------------------------- /tests/features/auth/test_controller.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | from unittest.mock import MagicMock, patch 3 | import pytest 4 | from datetime import timedelta # Add this import 5 | from qna_api.main import app 6 | from qna_api.features.auth.auth_service import AuthService 7 | from qna_api.features.auth.controller import AuthController 8 | from qna_api.core.config import settings # Make sure to import settings 9 | 10 | # Fixture for the test client 11 | @pytest.fixture 12 | def client(): 13 | with TestClient(app) as client: 14 | yield client 15 | 16 | # Fixture for the authentication service 17 | @pytest.fixture 18 | def auth_service(): 19 | return MagicMock(spec=AuthService) 20 | 21 | # Fixture for the authentication controller 22 | @pytest.fixture 23 | def auth_controller(auth_service): 24 | controller = AuthController(auth_service=auth_service) 25 | app.include_router(controller.router, prefix="/auth") 26 | return controller 27 | 28 | @patch.object(AuthService, 'authenticate_user') 29 | @patch.object(AuthService, 'create_access_token') 30 | def test_authenticate_success(mock_create_access_token, mock_authenticate_user, client, auth_service, auth_controller): 31 | # Mock the methods of the service 32 | mock_authenticate_user.return_value = MagicMock(username="testuser", roles=["user"]) 33 | mock_create_access_token.return_value = "testtoken" 34 | 35 | # Simulate form data 36 | form_data = { 37 | "username": "testuser", 38 | "password": "testpassword" 39 | } 40 | 41 | # Simulate the request 42 | response = client.post("/auth/token", data=form_data) 43 | 44 | # Log the response for debugging 45 | print("Response status code:", response.status_code) 46 | print("Response body:", response.json()) 47 | 48 | # Check the result 49 | assert response.status_code == 200 50 | assert response.json() == {"access_token": "testtoken", "token_type": "bearer"} 51 | 52 | # Ensure that the mock methods were called as expected 53 | mock_authenticate_user.assert_called_once_with("testuser", "testpassword") 54 | mock_create_access_token.assert_called_once_with(data={"sub": "testuser", "roles": ["user"]}, expires_delta=timedelta(minutes=settings.access_token_expire_minutes)) 55 | 56 | @patch.object(AuthService, 'authenticate_user') 57 | def test_authenticate_failure(mock_authenticate_user, client, auth_service, auth_controller): 58 | # Mock the authentication method to return None 59 | mock_authenticate_user.return_value = None 60 | 61 | # Simulate form data 62 | form_data = { 63 | "username": "testuser", 64 | "password": "wrongpassword" 65 | } 66 | 67 | # Simulate the request 68 | response = client.post("/auth/token", data=form_data) 69 | 70 | # Log the response for debugging 71 | print("Response status code:", response.status_code) 72 | print("Response body:", response.json()) 73 | 74 | # Check the result 75 | assert response.status_code == 401 76 | assert response.json() == {"detail": "Incorrect username or password"} 77 | 78 | # Ensure that the mock method was called as expected 79 | mock_authenticate_user.assert_called_once_with("testuser", "wrongpassword") 80 | -------------------------------------------------------------------------------- /tests/features/user/test_controller.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | import pytest 3 | from fastapi.testclient import TestClient 4 | from unittest.mock import MagicMock 5 | from qna_api.main import create_app 6 | from qna_api.crosscutting.authorization import get_authenticated_user 7 | from qna_api.features.user.models import User 8 | 9 | from .models import mock_new_user, mock_updated_user, mock_updated_user 10 | 11 | # Configure Test Client 12 | @pytest.fixture 13 | def client(mediator, authenticated_user): 14 | app = create_app(mediator=mediator) 15 | 16 | def _get_authenticated_user(): 17 | return authenticated_user 18 | 19 | app.dependency_overrides[get_authenticated_user] = _get_authenticated_user 20 | 21 | with TestClient(app) as client: 22 | yield client 23 | 24 | # Mock mediator 25 | @pytest.fixture 26 | def mediator(): 27 | return MagicMock(spec=Mediator) 28 | 29 | # Mock authenticated user 30 | @pytest.fixture 31 | def authenticated_user(): 32 | return User(id=1, username="testuser", full_name="Test User", email="testuser@example.com", roles=["user"]) 33 | 34 | def test_create_user(client, mediator): 35 | mediator.send_async.return_value = mock_new_user 36 | 37 | response = client.post("/user/signup", json={"username": "newuser", "email": "newuser@example.com", "full_name": "New User", "password": "password123"}) 38 | 39 | assert response.status_code == 200 40 | data = response.json() 41 | user = data["user"] 42 | assert user["username"] == "newuser" 43 | assert user["email"] == "newuser@example.com" 44 | assert user["full_name"] == "New User" 45 | mediator.send_async.assert_called_once() 46 | 47 | def test_create_user_value_error(client, mediator): 48 | mediator.send_async.side_effect = ValueError("Test error") 49 | 50 | response = client.post("/user/signup", json={"username": "newuser", "email": "newuser@example.com", "full_name": "New User", "password": "password123"}) 51 | 52 | assert response.status_code == 400 53 | assert response.json() == {"detail": "Test error"} 54 | mediator.send_async.assert_called_once() 55 | 56 | def test_me(client): 57 | response = client.get("user/me") 58 | 59 | assert response.status_code == 200 60 | data = response.json() 61 | assert data["username"] == "testuser" 62 | assert data["email"] == "testuser@example.com" 63 | assert data["full_name"] == "Test User" 64 | 65 | def test_update_user(client, mediator): 66 | mediator.send_async.return_value = mock_updated_user 67 | 68 | response = client.put("user/1", json={"username": "updateduser", "email": "updateduser@example.com", "full_name": "Updated User"}) 69 | 70 | assert response.status_code == 200 71 | data = response.json() 72 | assert data["username"] == mock_updated_user.username 73 | assert data["email"] == mock_updated_user.email 74 | assert data["full_name"] == mock_updated_user.full_name 75 | mediator.send_async.assert_called_once() 76 | 77 | def test_update_user_value_error(client, mediator): 78 | mediator.send_async.side_effect = ValueError("Test error") 79 | 80 | response = client.put("user/1", json={"username": "updateduser", "email": "updateduser@example.com", "full_name": "Updated User"}) 81 | 82 | assert response.status_code == 400 83 | assert response.json() == {"detail": "Test error"} 84 | mediator.send_async.assert_called_once() 85 | -------------------------------------------------------------------------------- /qna_api/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.concurrency import asynccontextmanager 3 | from fastapi.responses import RedirectResponse 4 | from mediatr import Mediator 5 | from qna_api.features.admin.controller import AdminController 6 | from qna_api.features.answers.controller import AnswerController 7 | from qna_api.features.auth.auth_service import AuthService 8 | from qna_api.features.auth.controller import AuthController 9 | from qna_api.core.database import init_db 10 | from qna_api.crosscutting.logging import get_logger 11 | from qna_api.features.questions.controller import QuestionController 12 | from qna_api.features.user.controller import UserController 13 | from qna_api.features.user.repository import UserRepository 14 | from qna_api.core.constants import TITLE, DESCRIPTION, CONTACT, LICENSE_INFO, SWAGGER_UI_PARAMETERS, SWAGGER_FAVICON_URL 15 | 16 | import debugpy 17 | import os 18 | import uvicorn 19 | 20 | from qna_api.features.vote.controller import VotesController 21 | 22 | logger = get_logger(__name__) 23 | 24 | @asynccontextmanager 25 | async def lifespan(app: FastAPI): 26 | init_db() 27 | yield 28 | 29 | def create_app( 30 | mediator=None, 31 | auth_service=None, 32 | notification_service=None): 33 | app = FastAPI( 34 | title=TITLE, 35 | description=DESCRIPTION, 36 | version="0.1", 37 | contact=CONTACT, 38 | license_info=LICENSE_INFO, 39 | swagger_ui_parameters=SWAGGER_UI_PARAMETERS, 40 | swagger_favicon_url=SWAGGER_FAVICON_URL, 41 | lifespan=lifespan 42 | ) 43 | 44 | user_repository = UserRepository.instance() 45 | 46 | if not mediator: 47 | mediator = Mediator() 48 | if not auth_service: 49 | auth_service = AuthService(user_repository) 50 | 51 | # NOTE: This is an example on how to register handlers that has crosscurring dependencies: 52 | # if not notification_service: 53 | # notification_service = NotificationService(constructor_params) 54 | # mediator.register_handler(SignupCommandHandler(user_repository, notification_service) # - Instantiate the class with its dependenices, 55 | # .handle, # - Pass the method to be called 56 | # SignupCommand) # - Pass the Request class to be handled 57 | 58 | auth_controller = AuthController(auth_service) 59 | user_controller = UserController(mediator) 60 | question_controller = QuestionController(mediator) 61 | answer_controller = AnswerController(mediator) 62 | votes_controller = VotesController(mediator) 63 | admin_controller = AdminController(mediator) 64 | 65 | app.include_router(auth_controller.router, prefix="/auth", tags=["auth"]) 66 | app.include_router(user_controller.router, prefix="/user", tags=["user"]) 67 | app.include_router(question_controller.router, prefix="/question", tags=["question"]) 68 | app.include_router(answer_controller.router, prefix="/question", tags=["Answer"]) 69 | app.include_router(votes_controller.router, tags=["Vote"]) 70 | app.include_router(admin_controller.router, prefix="/admin", tags=["admin"]) 71 | 72 | return app 73 | 74 | app = create_app() 75 | 76 | 77 | @app.get("/", include_in_schema=False, response_class=RedirectResponse) 78 | async def redirect_to_swagger(): 79 | logger.info("Redirect to swagger...") 80 | return RedirectResponse(url="/docs") 81 | 82 | if __name__ == "__main__": 83 | if os.getenv("DEBUG_MODE") == "true": 84 | debugpy.listen(("0.0.0.0", 5678)) 85 | debugpy.wait_for_client() 86 | uvicorn.run(app, host="0.0.0.0", port=8000) 87 | -------------------------------------------------------------------------------- /tests/features/auth/test_verification.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | import pytest 3 | from datetime import datetime, timedelta, timezone 4 | from jose import jwt 5 | from fastapi import HTTPException, status 6 | from qna_api.core.config import settings 7 | from qna_api.domain.user import UserEntity 8 | from qna_api.features.auth.auth_service import AuthService 9 | from qna_api.features.user.repository import UserRepository 10 | 11 | # Create Fake UserRepository so that this test doesn't depend on the actual database 12 | class FakeUserRepository(): 13 | _instance = None 14 | def __init__(self): 15 | self.users = { 16 | "testuser": UserEntity( 17 | id=1, 18 | username="testuser", 19 | email="test@example.com", 20 | full_name="Test User", 21 | hashed_password="$2b$12$KIX/Qd/bZ5at5fYniGkZkeWGyVgt9DZyZye69psA3kFhi5LbYEmBu", # 'password' hashed 22 | roles="user" 23 | ), 24 | "admin": UserEntity( 25 | id=2, 26 | username="admin", 27 | email="admin@example.com", 28 | full_name="Admin User", 29 | hashed_password="$2b$12$KIX/Qd/bZ5at5fYniGkZkeWGyVgt9DZyZye69psA3kFhi5LbYEmBu", # 'password' hashed 30 | roles="admin" 31 | ) 32 | } 33 | 34 | def get_by_username(self, username: str) -> UserEntity | None: 35 | return self.users.get(username) 36 | 37 | @pytest.fixture 38 | def user_repo(): 39 | return MagicMock(spec=FakeUserRepository) 40 | 41 | @pytest.fixture 42 | def auth_service(user_repo): 43 | return AuthService(user_repo) 44 | 45 | def test_create_verification_token(auth_service): 46 | user_id = 1 47 | token = auth_service.create_verification_token(user_id) 48 | assert token is not None 49 | payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) 50 | assert payload["user_id"] == user_id 51 | assert "exp" in payload 52 | 53 | def test_create_verification_token_with_custom_expiration(auth_service): 54 | user_id = 1 55 | expires_delta = timedelta(minutes=30) 56 | token = auth_service.create_verification_token(user_id, expires_delta) 57 | assert token is not None 58 | payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) 59 | assert payload["user_id"] == user_id 60 | assert "exp" in payload 61 | expire_time = datetime.fromtimestamp(payload["exp"], timezone.utc) 62 | assert expire_time < datetime.now(timezone.utc) + timedelta(hours=24) 63 | 64 | def test_decode_verification_token(auth_service): 65 | user_id = 1 66 | token = auth_service.create_verification_token(user_id) 67 | payload = auth_service.decode_verification_token(token) 68 | assert payload["user_id"] == user_id 69 | 70 | def test_decode_verification_token_expired(auth_service): 71 | user_id = 1 72 | expires_delta = timedelta(seconds=1) 73 | token = auth_service.create_verification_token(user_id, expires_delta) 74 | import time; time.sleep(2) # Ensure the token has expired 75 | with pytest.raises(HTTPException) as exc_info: 76 | auth_service.decode_verification_token(token) 77 | assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST 78 | assert exc_info.value.detail == "Token has expired" 79 | 80 | def test_decode_verification_token_invalid(auth_service): 81 | invalid_token = jwt.encode({"some": "data"}, "wrong_secret", algorithm=settings.algorithm) 82 | with pytest.raises(HTTPException) as exc_info: 83 | auth_service.decode_verification_token(invalid_token) 84 | assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST 85 | assert exc_info.value.detail == "Invalid token" 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI REST API Tutorial 2 | 3 | Este repositorio es un ejemplo de una API RESTful implementada con FastAPI. Incluye conceptos como DDD, Vertical Slices, Inyección de Dependencias, Seguridad, Swagger y más. 4 | 5 | 6 | ## Tutoriales en Video 7 | 8 | ### Video 1: Introducción y Configuración Inicial 9 | En este vídeo tratamos la versión inicial, básica pero funcional, aunque claro, no hay seguridad ni ninguna de las técnicas que se han desarrollado a posteriori. 10 | 11 | - [Ver en YouTube](https://youtu.be/9oUlpbcC8BQ) 12 | - [Código correspondiente a este vídeo](https://github.com/jgcarmona-com/fastapi-rest-api-tutorial/tree/fa96b75) 13 | 14 | ### Vídeo 2: 15 | TODO: grabar y publicar el vídeo. 16 | 17 | ## Características 18 | 19 | - **FastAPI**: Framework web moderno y de alto rendimiento para construir APIs con Python 3.7+ basado en estándares como OpenAPI y JSON Schema. 20 | - **RESTful API**: Diseño de la API siguiendo los principios REST. 21 | - **Domain-Driven Design (DDD)**: Separación clara de las responsabilidades y lógica del dominio. 22 | - **Vertical Slices**: Organización del código por características en lugar de por capas técnicas. 23 | - **Inyección de Dependencias**: Gestión e inyección de dependencias con FastAPI. 24 | - **Seguridad**: Implementación de autenticación y autorización. 25 | - **Swagger**: Documentación interactiva de la API. 26 | 27 | ## Estructura del Proyecto 28 | 29 | ``` 30 | qna_api/ 31 | │ 32 | ├── auth/ 33 | │ ├── models.py 34 | │ ├── routes.py 35 | │ └── service.py 36 | │ 37 | ├── core/ 38 | │ ├── base_repository.py 39 | │ ├── config.py 40 | │ ├── constants.py 41 | │ ├── database.py 42 | │ └── logging.py 43 | │ 44 | ├── domain/ 45 | │ ├── answer.py 46 | │ ├── question.py 47 | │ └── user.py 48 | │ 49 | ├── questions/ 50 | │ ├── models.py 51 | │ ├── repositories.py 52 | │ ├── routes.py 53 | │ └── services.py 54 | │ 55 | └── user/ 56 | ├── controller.py 57 | ├── models.py 58 | ├── repository.py 59 | └── service.py 60 | ``` 61 | 62 | ## Instalación 63 | 64 | 1. Clona el repositorio: 65 | ```bash 66 | git clone https://github.com/jgcarmona-com/fastapi-rest-api-tutorial.git 67 | ``` 68 | 69 | 2. Navega al directorio del proyecto: 70 | ```bash 71 | cd fastapi-rest-api-tutorial 72 | ``` 73 | 74 | 3. Crea un entorno virtual y activa: 75 | ```bash 76 | python -m venv .venv 77 | source venv/bin/activate # En Windows usa `venv\Scripts\activate` 78 | ``` 79 | 80 | 4. Instala las dependencias: 81 | ```bash 82 | pip install -r requirements.txt 83 | ``` 84 | 85 | 5. Configura las variables de entorno: 86 | Antes de iniciar la aplicación, necesitas configurar las variables de entorno. Sigue estos pasos: 87 | 88 | 1. Copia el archivo `.env.example` a `.env`: 89 | ```sh 90 | cp .env.example .env 91 | ``` 92 | 93 | 2. Rellena las variables de entorno en el archivo `.env` con tus propios valores: 94 | ``` 95 | SECRET_KEY=your_secret_key 96 | ALGORITHM=HS256 97 | ACCESS_TOKEN_EXPIRE_MINUTES=30 98 | DATABASE_URL=sqlite:///./sql_app.db 99 | INITIAL_ADMIN_USERNAME=admin 100 | INITIAL_ADMIN_EMAIL=admin@example.com 101 | INITIAL_ADMIN_PASSWORD=admin 102 | ``` 103 | 104 | 6. Inicia la aplicación: 105 | Desde vscode selecciona la opción local o docker y ejecútalo. 106 | 107 | ## Uso 108 | 109 | 1. Visita `http://127.0.0.1:8000` y te redirigirá a /doc para ver la documentación interactiva de la API (swagger). 110 | 2. Visita `http://127.0.0.1:8000/redoc` para ver la documentación alternativa de la API. 111 | 112 | ## Enlaces útiles 113 | 114 | - [Documentación de FastAPI](https://fastapi.tiangolo.com/) 115 | - [Repositorio en GitHub](https://github.com/jgcarmona-com/fastapi-rest-api-tutorial) 116 | 117 | ## Versiones anteriores 118 | 119 | - [Versión del primer vídeo](https://github.com/jgcarmona-com/fastapi-rest-api-tutorial/tree/fa96b75) 120 | - [Primer vídeo en YouTube](https://youtu.be/9oUlpbcC8BQ) 121 | -------------------------------------------------------------------------------- /qna_api/features/answers/controller.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException, status 2 | from mediatr import Mediator 3 | from qna_api.features.answers.models import AnswerCreate, AnswerUpdate, Answer 4 | from qna_api.crosscutting.authorization import get_authenticated_user 5 | from qna_api.crosscutting.logging import get_logger 6 | from qna_api.features.user.models import User 7 | from qna_api.features.answers.queries.get_answer_query import GetAnswerQuery 8 | from qna_api.features.answers.commands.update_answer_command import UpdateAnswerCommand 9 | from qna_api.features.answers.commands.delete_answer_command import DeleteAnswerCommand 10 | from qna_api.features.answers.commands.add_answer_command import AddAnswerCommand 11 | from qna_api.features.answers.queries.get_question_answers_query import GetQuestionAnswersQuery 12 | from typing import List 13 | 14 | logger = get_logger(__name__) 15 | 16 | class AnswerController: 17 | def __init__(self, mediator: Mediator): 18 | self.mediator = mediator 19 | self.router = APIRouter() 20 | self._add_routes() 21 | 22 | def _add_routes(self): 23 | self.router.get("/{question_id}/answer/{answer_id}", response_model=Answer)(self.get_answer) 24 | self.router.put("/{question_id}/answer/{answer_id}", response_model=Answer)(self.update_answer) 25 | self.router.delete("/{question_id}/answer/{answer_id}", response_model=Answer)(self.delete_answer) 26 | self.router.post("/{question_id}/answer", response_model=Answer)(self.add_answer) 27 | self.router.get("/{question_id}/answers", response_model=List[Answer])(self.get_question_answers) 28 | 29 | async def get_answer(self, question_id: int, answer_id: int, current_user: User = Depends(get_authenticated_user)): 30 | logger.info(f"{current_user.full_name} is getting answer {answer_id} for question {question_id}") 31 | try: 32 | answer = await self.mediator.send_async(GetAnswerQuery(answer_id)) 33 | return answer 34 | except ValueError as e: 35 | logger.error(f"Answer with id {answer_id} not found") 36 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 37 | 38 | async def update_answer(self, question_id: int, answer_id: int, answer: AnswerUpdate, current_user: User = Depends(get_authenticated_user)): 39 | logger.info(f"{current_user.full_name} is updating answer {answer_id} for question {question_id}") 40 | try: 41 | return await self.mediator.send_async(UpdateAnswerCommand(answer_id, answer)) 42 | except ValueError as e: 43 | logger.error(f"Error updating answer: {str(e)}") 44 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 45 | 46 | async def delete_answer(self, question_id: int, answer_id: int, current_user: User = Depends(get_authenticated_user)): 47 | logger.info(f"{current_user.full_name} is deleting answer {answer_id} for question {question_id}") 48 | try: 49 | return await self.mediator.send_async(DeleteAnswerCommand(answer_id)) 50 | except ValueError as e: 51 | logger.error(f"Error deleting answer: {str(e)}") 52 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 53 | 54 | async def add_answer(self, question_id: int, answer: AnswerCreate, current_user: User = Depends(get_authenticated_user)): 55 | logger.info(f"{current_user.full_name} is creating an answer for question {question_id}") 56 | try: 57 | created_answer = await self.mediator.send_async(AddAnswerCommand(question_id, answer, current_user.id)) 58 | return Answer.model_validate(created_answer, from_attributes=True) 59 | except ValueError as e: 60 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 61 | 62 | async def get_question_answers(self, question_id: int, current_user: User = Depends(get_authenticated_user)): 63 | logger.info(f"{current_user.full_name} is getting answers for question {question_id}") 64 | try: 65 | answers = await self.mediator.send_async(GetQuestionAnswersQuery(question_id)) 66 | return [Answer.model_validate(answer, from_attributes=True) for answer in answers] 67 | except ValueError as e: 68 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 69 | -------------------------------------------------------------------------------- /tests/core/test_base_repository.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import MagicMock, patch 3 | from sqlalchemy.orm import Session 4 | from sqlalchemy.exc import IntegrityError 5 | 6 | from qna_api.core.database import get_db 7 | from qna_api.core.base_repository import BaseRepository 8 | from typing import Type, TypeVar 9 | 10 | T = TypeVar('T') 11 | 12 | class MockModel: 13 | def __init__(self, id=None, name=None): 14 | self.id = id 15 | self.name = name 16 | 17 | @pytest.fixture 18 | def db_session(): 19 | return MagicMock(spec=Session) 20 | 21 | @pytest.fixture 22 | def base_repository(db_session): 23 | return BaseRepository(MockModel, db_session) 24 | 25 | def test_get(base_repository, db_session): 26 | mock_obj = MockModel(id=1, name="Test") 27 | db_session.query().get.return_value = mock_obj 28 | 29 | result = base_repository.get(1) 30 | assert result == mock_obj 31 | db_session.query().get.assert_called_once_with(1) 32 | 33 | def test_get_all(base_repository, db_session): 34 | mock_objs = [MockModel(id=1, name="Test1"), MockModel(id=2, name="Test2")] 35 | db_session.query().all.return_value = mock_objs 36 | 37 | result = base_repository.get_all() 38 | assert result == mock_objs 39 | db_session.query().all.assert_called_once() 40 | 41 | def test_create(base_repository, db_session): 42 | mock_obj = MockModel(id=1, name="Test") 43 | db_session.add.side_effect = lambda x: setattr(x, 'id', 1) 44 | db_session.commit.return_value = None 45 | db_session.refresh.return_value = None 46 | 47 | result = base_repository.create(mock_obj) 48 | assert result.id == 1 49 | db_session.add.assert_called_once_with(mock_obj) 50 | db_session.commit.assert_called_once() 51 | db_session.refresh.assert_called_once_with(mock_obj) 52 | 53 | def test_create_integrity_error(base_repository, db_session): 54 | mock_obj = MockModel(name="Test") 55 | db_session.add.side_effect = IntegrityError("mock", "params", "orig") 56 | db_session.rollback.return_value = None 57 | 58 | with patch.object(base_repository, 'refresh_db') as refresh_db_mock: 59 | with pytest.raises(ValueError): 60 | base_repository.create(mock_obj) 61 | db_session.rollback.assert_called_once() 62 | refresh_db_mock.assert_called_once() 63 | 64 | def test_update(base_repository, db_session): 65 | mock_obj = MockModel(id=1, name="Updated Test") 66 | db_session.merge.return_value = mock_obj 67 | db_session.commit.return_value = None 68 | 69 | result = base_repository.update(mock_obj) 70 | assert result == mock_obj 71 | db_session.merge.assert_called_once_with(mock_obj) 72 | db_session.commit.assert_called_once() 73 | 74 | def test_update_integrity_error(base_repository, db_session): 75 | mock_obj = MockModel(id=1, name="Updated Test") 76 | db_session.merge.side_effect = IntegrityError("mock", "params", "orig") 77 | db_session.rollback.return_value = None 78 | 79 | with patch.object(base_repository, 'refresh_db') as refresh_db_mock: 80 | with pytest.raises(ValueError): 81 | base_repository.update(mock_obj) 82 | db_session.rollback.assert_called_once() 83 | refresh_db_mock.assert_called_once() 84 | 85 | def test_delete(base_repository, db_session): 86 | mock_obj = MockModel(id=1, name="Test") 87 | db_session.query().get.return_value = mock_obj 88 | db_session.delete.return_value = None 89 | db_session.commit.return_value = None 90 | 91 | base_repository.delete(1) 92 | db_session.delete.assert_called_once_with(mock_obj) 93 | db_session.commit.assert_called_once() 94 | 95 | def test_refresh_db(base_repository): 96 | with patch('qna_api.core.base_repository.get_db', return_value=iter([MagicMock()])): 97 | base_repository.refresh_db() 98 | assert base_repository.db is not None 99 | 100 | def test_parse_integrity_error(base_repository): 101 | error_message = "UNIQUE constraint failed: table.column" 102 | integrity_error = IntegrityError(statement="mock_statement", params="mock_params", orig=Exception(error_message)) 103 | 104 | parsed_message = base_repository._parse_integrity_error(integrity_error) 105 | assert parsed_message == "Duplicate entry for column in table. Please choose a different value." 106 | 107 | -------------------------------------------------------------------------------- /tests/features/auth/test_authorization.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import Depends 3 | from fastapi.testclient import TestClient 4 | from jose import jwt 5 | from datetime import datetime, timedelta, timezone 6 | from qna_api.crosscutting.authorization import get_admin_user, get_authenticated_user 7 | from qna_api.main import app 8 | from qna_api.core.config import settings 9 | from qna_api.features.user.repository import UserRepository 10 | from qna_api.domain.user import UserEntity 11 | 12 | # Configure Test Client 13 | client = TestClient(app) 14 | 15 | # Create Fake UserRepository so that this test doesn't depend on the actual database 16 | class FakeUserRepository(UserRepository): 17 | _instance = None 18 | def __init__(self): 19 | self.users = { 20 | "testuser": UserEntity( 21 | id=1, 22 | username="testuser", 23 | email="test@example.com", 24 | full_name="Test User", 25 | hashed_password="$2b$12$KIX/Qd/bZ5at5fYniGkZkeWGyVgt9DZyZye69psA3kFhi5LbYEmBu", # 'password' hashed 26 | roles="user" 27 | ), 28 | "admin": UserEntity( 29 | id=2, 30 | username="admin", 31 | email="admin@example.com", 32 | full_name="Admin User", 33 | hashed_password="$2b$12$KIX/Qd/bZ5at5fYniGkZkeWGyVgt9DZyZye69psA3kFhi5LbYEmBu", # 'password' hashed 34 | roles="admin" 35 | ) 36 | } 37 | 38 | @classmethod 39 | def instance(cls): 40 | if cls._instance is None: 41 | cls._instance = cls() 42 | return cls._instance 43 | 44 | 45 | def get_by_username(self, username: str) -> UserEntity | None: 46 | return self.users.get(username) 47 | 48 | @pytest.fixture 49 | def authenticated_user_token(): 50 | expire = datetime.now(timezone.utc) + timedelta(minutes=15) 51 | to_encode = {"sub": "testuser", "exp": expire} 52 | return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) 53 | 54 | 55 | @pytest.fixture 56 | def authenticated_admin_token(): 57 | expire = datetime.now(timezone.utc) + timedelta(minutes=15) 58 | to_encode = {"sub": "admin", "exp": expire} 59 | return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) 60 | 61 | 62 | def test_get_authenticated_user(authenticated_user_token): 63 | app.dependency_overrides[UserRepository.instance] = FakeUserRepository.instance 64 | 65 | response = client.get("/some_protected_route", headers={"Authorization": f"Bearer {authenticated_user_token}"}) 66 | assert response.status_code == 200 67 | assert response.json() == {"username": "testuser"} 68 | 69 | def test_unauthenticated_user(): 70 | app.dependency_overrides[UserRepository.instance] = FakeUserRepository.instance 71 | 72 | response = client.get("/some_protected_route") 73 | assert response.status_code == 401 74 | assert response.json() == {"detail": "Not authenticated"} 75 | 76 | def test_get_authenticated_admin(authenticated_admin_token): 77 | app.dependency_overrides[UserRepository.instance] = FakeUserRepository.instance 78 | 79 | response = client.get("/some_admin_only_route", headers={"Authorization": f"Bearer {authenticated_admin_token}"}) 80 | assert response.status_code == 200 81 | assert response.json() == {"username": "admin"} 82 | 83 | def test_unauthenticated_admin(): 84 | app.dependency_overrides[UserRepository.instance] = FakeUserRepository.instance 85 | 86 | response = client.get("/some_admin_only_route") 87 | assert response.status_code == 401 88 | assert response.json() == {"detail": "Not authenticated"} 89 | 90 | def test_unauthorized_admin(authenticated_user_token): 91 | app.dependency_overrides[UserRepository.instance] = FakeUserRepository.instance 92 | 93 | response = client.get("/some_admin_only_route", headers={"Authorization": f"Bearer {authenticated_user_token}"}) 94 | assert response.status_code == 403 95 | assert response.json() == {"detail": "You do not have the necessary permissions"} 96 | 97 | # Sample route for testing that requires authenticated users 98 | @app.get("/some_protected_route") 99 | def some_protected_route(current_user: UserEntity = Depends(get_authenticated_user)): 100 | return {"username": current_user.username} 101 | 102 | # Sample route for testing only reacheable by admin users 103 | @app.get("/some_admin_only_route") 104 | def some_protected_route(current_user: UserEntity = Depends(get_admin_user)): 105 | return {"username": current_user.username} 106 | -------------------------------------------------------------------------------- /qna_api/features/questions/controller.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException, status 2 | from mediatr import Mediator 3 | from qna_api.features.answers.models import AnswerCreate, Answer 4 | from qna_api.crosscutting.authorization import get_admin_user, get_authenticated_user 5 | from qna_api.crosscutting.logging import get_logger 6 | from qna_api.features.answers.commands.add_answer_command import AddAnswerCommand 7 | from qna_api.features.questions.commands.create_question_command import CreateQuestionCommand 8 | from qna_api.features.questions.commands.delete_question_command import DeleteQuestionCommand 9 | from qna_api.features.questions.commands.update_question_command import UpdateQuestionCommand 10 | from qna_api.features.questions.models import FullQuestion, QuestionCreate, Question 11 | from qna_api.features.questions.queries.get_all_questions_query import GetAllQuestionsQuery 12 | from qna_api.features.questions.queries.get_full_question_query import GetFullQuestionQuery 13 | from qna_api.features.answers.queries.get_question_answers_query import GetQuestionAnswersQuery 14 | from qna_api.features.questions.queries.get_question_query import GetQuestionQuery 15 | from qna_api.features.user.models import User 16 | from typing import List 17 | 18 | logger = get_logger(__name__) 19 | 20 | class QuestionController: 21 | def __init__(self, mediator: Mediator): 22 | self.mediator = mediator 23 | self.router = APIRouter() 24 | self._add_routes() 25 | 26 | def _add_routes(self): 27 | self.router.get("/", response_model=List[Question])(self.get_all_questions) 28 | self.router.post("/", response_model=Question)(self.create_question) 29 | self.router.get("/{question_id}", response_model=Question)(self.get_question) 30 | self.router.get("/{question_id}/full", response_model=FullQuestion)(self.get_full_question) 31 | self.router.put("/{question_id}", response_model=Question)(self.update_question) 32 | self.router.delete("/{question_id}", response_model=Question)(self.delete_question) 33 | 34 | async def create_question(self, question: QuestionCreate, current_user: User = Depends(get_authenticated_user)): 35 | try: 36 | created_question = await self.mediator.send_async(CreateQuestionCommand(question, current_user.id)) 37 | return Question.model_validate(created_question, from_attributes=True) 38 | except ValueError as e: 39 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 40 | 41 | async def get_all_questions(self, current_user: User = Depends(get_admin_user)): 42 | try: 43 | questions = await self.mediator.send_async(GetAllQuestionsQuery()) 44 | return [Question.model_validate(q, from_attributes=True) for q in questions] 45 | except ValueError as e: 46 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 47 | 48 | async def get_question(self, question_id: int, current_user: User = Depends(get_authenticated_user)): 49 | try: 50 | question = await self.mediator.send_async(GetQuestionQuery(question_id)) 51 | if not question: 52 | raise ValueError(f"Question {question_id} not found") 53 | return Question.model_validate(question, from_attributes=True) 54 | except ValueError as e: 55 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 56 | 57 | async def get_full_question(self, question_id: int, current_user: User = Depends(get_authenticated_user)): 58 | try: 59 | question = await self.mediator.send_async(GetFullQuestionQuery(question_id)) 60 | return FullQuestion.model_validate(question, from_attributes=True) 61 | except ValueError as e: 62 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 63 | 64 | async def update_question(self, question_id: int, question: QuestionCreate, current_user: User = Depends(get_authenticated_user)): 65 | try: 66 | updated_question = await self.mediator.send_async(UpdateQuestionCommand(question_id, question, current_user.id)) 67 | return Question.model_validate(updated_question, from_attributes=True) 68 | except ValueError as e: 69 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 70 | 71 | async def delete_question(self, question_id: int, current_user: User = Depends(get_authenticated_user)): 72 | try: 73 | deleted_question = await self.mediator.send_async(DeleteQuestionCommand(question_id, current_user.id)) 74 | return Question.model_validate(deleted_question, from_attributes=True) 75 | except ValueError as e: 76 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 77 | 78 | -------------------------------------------------------------------------------- /tests/features/answers/test_controller.py: -------------------------------------------------------------------------------- 1 | from mediatr import Mediator 2 | import pytest 3 | from fastapi.testclient import TestClient 4 | from unittest.mock import MagicMock 5 | from qna_api.main import create_app 6 | from qna_api.crosscutting.authorization import get_authenticated_user 7 | from qna_api.features.user.models import User 8 | from .models import mock_answer, mock_updated_answer 9 | 10 | # Configure Test Client 11 | @pytest.fixture 12 | def client(mediator, authenticated_user): 13 | app = create_app(mediator=mediator) 14 | 15 | def _get_authenticated_user(): 16 | return authenticated_user 17 | 18 | app.dependency_overrides[get_authenticated_user] = _get_authenticated_user 19 | 20 | with TestClient(app) as client: 21 | yield client 22 | 23 | # Mock mediator 24 | @pytest.fixture 25 | def mediator(): 26 | return MagicMock(spec=Mediator) 27 | 28 | # Mock authenticated user 29 | @pytest.fixture 30 | def authenticated_user(): 31 | return User(id=1, username="testuser", full_name="Test User", email="testuser@example.com", roles=["user"]) 32 | 33 | def test_add_answer(client, mediator): 34 | mediator.send_async.return_value = mock_answer 35 | 36 | response = client.post("/question/1/answer", json={"content": "This is an answer"}) 37 | 38 | mediator.send_async.assert_called_once() 39 | assert response.status_code == 200 40 | data = response.json() 41 | assert data["content"] == "This is an answer" 42 | assert data["question_id"] == 1 43 | 44 | def test_get_answer(client, mediator): 45 | mediator.send_async.return_value = mock_answer 46 | 47 | response = client.get("question/1/answer/1") 48 | 49 | assert response.status_code == 200 50 | data = response.json() 51 | assert data["id"] == mock_answer.id 52 | assert data["content"] == mock_answer.content 53 | assert data["question_id"] == mock_answer.question_id 54 | assert data["user_id"] == mock_answer.user_id 55 | mediator.send_async.assert_called_once() 56 | 57 | def test_get_answer_value_error(client, mediator): 58 | mediator.send_async.side_effect = ValueError("Answer with id 1 not found") 59 | 60 | response = client.get("question/1/answer/1") 61 | 62 | assert response.status_code == 404 63 | assert response.json() == {"detail": "Answer with id 1 not found"} 64 | mediator.send_async.assert_called_once() 65 | 66 | def test_update_answer(client, mediator): 67 | mediator.send_async.return_value = mock_updated_answer 68 | 69 | response = client.put("question/1/answer/1", json={"content": "This is an updated answer"}) 70 | 71 | assert response.status_code == 200 72 | data = response.json() 73 | assert data["id"] == mock_updated_answer.id 74 | assert data["content"] == mock_updated_answer.content 75 | assert data["question_id"] == mock_updated_answer.question_id 76 | assert data["user_id"] == mock_updated_answer.user_id 77 | mediator.send_async.assert_called_once() 78 | 79 | def test_update_answer_value_error(client, mediator): 80 | mediator.send_async.side_effect = ValueError("Answer with id 1 not found") 81 | 82 | response = client.put("question/1/answer/1", json={"content": "This is an updated answer"}) 83 | 84 | assert response.status_code == 404 85 | assert response.json() == {"detail": "Answer with id 1 not found"} 86 | mediator.send_async.assert_called_once() 87 | 88 | def test_delete_answer(client, mediator): 89 | mediator.send_async.return_value = mock_answer 90 | 91 | response = client.delete("question/1/answer/1") 92 | 93 | assert response.status_code == 200 94 | data = response.json() 95 | assert data["id"] == mock_answer.id 96 | assert data["content"] == mock_answer.content 97 | assert data["question_id"] == mock_answer.question_id 98 | assert data["user_id"] == mock_answer.user_id 99 | mediator.send_async.assert_called_once() 100 | 101 | def test_delete_answer_value_error(client, mediator): 102 | mediator.send_async.side_effect = ValueError("Answer with id 1 not found") 103 | 104 | response = client.delete("question/1/answer/1") 105 | 106 | assert response.status_code == 404 107 | assert response.json() == {"detail": "Answer with id 1 not found"} 108 | mediator.send_async.assert_called_once() 109 | 110 | def test_add_answer_value_error(client, mediator): 111 | mediator.send_async.side_effect = ValueError("Test error") 112 | 113 | response = client.post("/question/1/answer", json={"content": "This is an answer"}) 114 | 115 | assert response.status_code == 400 116 | assert response.json() == {"detail": "Test error"} 117 | 118 | def test_get_answers_value_error(client, mediator): 119 | mediator.send_async.side_effect = ValueError("Test error") 120 | 121 | response = client.get("/question/1/answers") 122 | 123 | assert response.status_code == 400 124 | assert response.json() == {"detail": "Test error"} 125 | 126 | def test_get_answers(client, mediator): 127 | mediator.send_async.return_value = [mock_answer] 128 | 129 | response = client.get("/question/1/answers") 130 | 131 | mediator.send_async.assert_called_once() 132 | assert response.status_code == 200 133 | data = response.json() 134 | assert isinstance(data, list) 135 | assert len(data) == 1 136 | -------------------------------------------------------------------------------- /tests/features/questions/test_controller.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | from unittest.mock import MagicMock 4 | from mediatr import Mediator 5 | from qna_api.crosscutting.notification_service import NotificationService 6 | from qna_api.main import create_app 7 | from qna_api.crosscutting.authorization import get_admin_user, get_authenticated_user 8 | from qna_api.features.user.models import User 9 | 10 | from .models import mock_question, mock_full_question, mock_questions_list, mock_answer 11 | 12 | # Configure Test Client 13 | @pytest.fixture 14 | def client(mediator, authenticated_user): 15 | app = create_app(mediator=mediator) 16 | 17 | def _get_authenticated_user(): 18 | return authenticated_user 19 | 20 | app.dependency_overrides[get_authenticated_user] = _get_authenticated_user 21 | 22 | with TestClient(app) as client: 23 | yield client 24 | 25 | # Mock mediator 26 | @pytest.fixture 27 | def mediator(): 28 | return MagicMock(spec=Mediator) 29 | 30 | # Mock NotificationService 31 | @pytest.fixture 32 | def notification_service(): 33 | return MagicMock(spec=NotificationService) 34 | 35 | # Mock authenticated user 36 | @pytest.fixture 37 | def authenticated_user(): 38 | return User(id=1, username="testuser", full_name="Test User", email="test@example.com", roles=["user"]) 39 | 40 | @pytest.fixture 41 | def admin_user(): 42 | return User(id=1, username="admin", full_name="Admin User", email="admin@example.com", roles=["user,admin"]) 43 | 44 | def test_create_question(client, mediator): 45 | mediator.send_async.return_value = mock_question 46 | 47 | response = client.post("/question/", json={"title": "Sample Question", "description": "This is a sample question"}) 48 | 49 | mediator.send_async.assert_called_once() 50 | assert response.status_code == 200 51 | data = response.json() 52 | assert data["title"] == "Sample Question" 53 | assert data["description"] == "This is a sample question" 54 | 55 | def test_get_all_questions(client, mediator, admin_user): 56 | app = create_app(mediator=mediator, notification_service=notification_service) 57 | 58 | def _get_admin_user(): 59 | return admin_user 60 | 61 | app.dependency_overrides[get_admin_user] = _get_admin_user 62 | 63 | with TestClient(app) as client: 64 | mediator.send_async.return_value = mock_questions_list 65 | 66 | response = client.get("/question") 67 | 68 | mediator.send_async.assert_called_once() 69 | assert response.status_code == 200 70 | data = response.json() 71 | assert isinstance(data, list) 72 | assert len(data) == 1 73 | 74 | def test_get_question_by_id(client, mediator): 75 | mediator.send_async.return_value = mock_question 76 | 77 | response = client.get("/question/1") 78 | 79 | mediator.send_async.assert_called_once() 80 | assert response.status_code == 200 81 | data = response.json() 82 | assert data["id"] == 1 83 | 84 | def test_get_nonexistent_question(client, mediator): 85 | mediator.send_async.return_value = None 86 | 87 | response = client.get("/question/999") 88 | 89 | mediator.send_async.assert_called_once() 90 | assert response.status_code == 404 91 | assert response.json() == {"detail": "Question 999 not found"} 92 | 93 | def test_get_full_question(client, mediator): 94 | mediator.send_async.return_value = mock_full_question 95 | 96 | response = client.get("/question/1/full") 97 | 98 | mediator.send_async.assert_called_once() 99 | assert response.status_code == 200 100 | data = response.json() 101 | assert data["id"] == 1 102 | assert "answers" in data 103 | assert len(data["answers"]) == len(mock_full_question.answers) 104 | 105 | def test_update_question(client, mediator): 106 | updated_question = mock_question.model_copy() 107 | updated_question.title = "Updated Question" 108 | updated_question.description = "This is an updated question" 109 | mediator.send_async.return_value = updated_question 110 | 111 | response = client.put("/question/1", json={"title": updated_question.title, "description": updated_question.description}) 112 | 113 | mediator.send_async.assert_called_once() 114 | assert response.status_code == 200 115 | data = response.json() 116 | assert data["title"] == updated_question.title 117 | assert data["description"] == updated_question.description 118 | 119 | def test_delete_question(client, mediator): 120 | mediator.send_async.return_value = mock_question 121 | 122 | response = client.delete("/question/1") 123 | 124 | mediator.send_async.assert_called_once() 125 | assert response.status_code == 200 126 | data = response.json() 127 | assert data["id"] == 1 128 | 129 | # Error handling tests 130 | 131 | def test_create_question_value_error(client, mediator): 132 | mediator.send_async.side_effect = ValueError("Test error") 133 | 134 | response = client.post("/question/", json={"title": "Sample Question", "description": "This is a sample question"}) 135 | 136 | assert response.status_code == 400 137 | assert response.json() == {"detail": "Test error"} 138 | 139 | def test_get_all_questions_value_error(client, mediator, admin_user): 140 | app = create_app(mediator=mediator) 141 | 142 | def _get_admin_user(): 143 | return admin_user 144 | 145 | app.dependency_overrides[get_admin_user] = _get_admin_user 146 | 147 | with TestClient(app) as client: 148 | mediator.send_async.side_effect = ValueError("Test error") 149 | 150 | response = client.get("/question") 151 | 152 | assert response.status_code == 400 153 | assert response.json() == {"detail": "Test error"} 154 | 155 | def test_get_question_value_error(client, mediator): 156 | mediator.send_async.side_effect = ValueError("Test error") 157 | 158 | response = client.get("/question/1") 159 | 160 | assert response.status_code == 404 161 | assert response.json() == {"detail": "Test error"} 162 | 163 | def test_get_full_question_value_error(client, mediator): 164 | mediator.send_async.side_effect = ValueError("Test error") 165 | 166 | response = client.get("/question/1/full") 167 | 168 | assert response.status_code == 404 169 | assert response.json() == {"detail": "Test error"} 170 | 171 | def test_update_question_value_error(client, mediator): 172 | mediator.send_async.side_effect = ValueError("Test error") 173 | 174 | response = client.put("/question/1", json={"title": "Updated Question", "description": "This is an updated question"}) 175 | 176 | assert response.status_code == 400 177 | assert response.json() == {"detail": "Test error"} 178 | 179 | def test_delete_question_value_error(client, mediator): 180 | mediator.send_async.side_effect = ValueError("Test error") 181 | 182 | response = client.delete("/question/1") 183 | 184 | assert response.status_code == 400 185 | assert response.json() == {"detail": "Test error"} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | *.env 13 | 14 | # Virtual environment (.venv) 15 | .venv/* 16 | # User-specific files (MonoDevelop/Xamarin Studio) 17 | *.userprefs 18 | 19 | # Ignore Local DBs 20 | *.db 21 | 22 | # Ignore htmlcov 23 | htmlcov/ 24 | 25 | # Mono auto generated files 26 | mono_crash.* 27 | 28 | # Build results 29 | [Dd]ebug/ 30 | [Dd]ebugPublic/ 31 | [Rr]elease/ 32 | [Rr]eleases/ 33 | x64/ 34 | x86/ 35 | [Ww][Ii][Nn]32/ 36 | [Aa][Rr][Mm]/ 37 | [Aa][Rr][Mm]64/ 38 | bld/ 39 | [Bb]in/ 40 | [Oo]bj/ 41 | [Ll]og/ 42 | [Ll]ogs/ 43 | 44 | # Visual Studio 2015/2017 cache/options directory 45 | .vs/ 46 | # Uncomment if you have tasks that create the project's static files in wwwroot 47 | #wwwroot/ 48 | 49 | # Visual Studio 2017 auto generated files 50 | Generated\ Files/ 51 | 52 | # MSTest test Results 53 | [Tt]est[Rr]esult*/ 54 | [Bb]uild[Ll]og.* 55 | 56 | # NUnit 57 | *.VisualState.xml 58 | TestResult.xml 59 | nunit-*.xml 60 | 61 | # Build Results of an ATL Project 62 | [Dd]ebugPS/ 63 | [Rr]eleasePS/ 64 | dlldata.c 65 | 66 | # Benchmark Results 67 | BenchmarkDotNet.Artifacts/ 68 | 69 | # .NET Core 70 | project.lock.json 71 | project.fragment.lock.json 72 | artifacts/ 73 | 74 | # ASP.NET Scaffolding 75 | ScaffoldingReadMe.txt 76 | 77 | # StyleCop 78 | StyleCopReport.xml 79 | 80 | # Files built by Visual Studio 81 | *_i.c 82 | *_p.c 83 | *_h.h 84 | *.ilk 85 | *.meta 86 | *.obj 87 | *.iobj 88 | *.pch 89 | *.pdb 90 | *.ipdb 91 | *.pgc 92 | *.pgd 93 | *.rsp 94 | *.sbr 95 | *.tlb 96 | *.tli 97 | *.tlh 98 | *.tmp 99 | *.tmp_proj 100 | *_wpftmp.csproj 101 | *.log 102 | *.tlog 103 | *.vspscc 104 | *.vssscc 105 | .builds 106 | *.pidb 107 | *.svclog 108 | *.scc 109 | 110 | # Chutzpah Test files 111 | _Chutzpah* 112 | 113 | # Visual C++ cache files 114 | ipch/ 115 | *.aps 116 | *.ncb 117 | *.opendb 118 | *.opensdf 119 | *.sdf 120 | *.cachefile 121 | *.VC.db 122 | *.VC.VC.opendb 123 | 124 | # Visual Studio profiler 125 | *.psess 126 | *.vsp 127 | *.vspx 128 | *.sap 129 | 130 | # Visual Studio Trace Files 131 | *.e2e 132 | 133 | # TFS 2012 Local Workspace 134 | $tf/ 135 | 136 | # Guidance Automation Toolkit 137 | *.gpState 138 | 139 | # ReSharper is a .NET coding add-in 140 | _ReSharper*/ 141 | *.[Rr]e[Ss]harper 142 | *.DotSettings.user 143 | 144 | # TeamCity is a build add-in 145 | _TeamCity* 146 | 147 | # DotCover is a Code Coverage Tool 148 | *.dotCover 149 | 150 | # AxoCover is a Code Coverage Tool 151 | .axoCover/* 152 | !.axoCover/settings.json 153 | 154 | # Coverlet is a free, cross platform Code Coverage Tool 155 | coverage*.json 156 | coverage*.xml 157 | coverage*.info 158 | 159 | # Visual Studio code coverage results 160 | *.coverage 161 | *.coveragexml 162 | 163 | # NCrunch 164 | _NCrunch_* 165 | .*crunch*.local.xml 166 | nCrunchTemp_* 167 | 168 | # MightyMoose 169 | *.mm.* 170 | AutoTest.Net/ 171 | 172 | # Web workbench (sass) 173 | .sass-cache/ 174 | 175 | # Installshield output folder 176 | [Ee]xpress/ 177 | 178 | # DocProject is a documentation generator add-in 179 | DocProject/buildhelp/ 180 | DocProject/Help/*.HxT 181 | DocProject/Help/*.HxC 182 | DocProject/Help/*.hhc 183 | DocProject/Help/*.hhk 184 | DocProject/Help/*.hhp 185 | DocProject/Help/Html2 186 | DocProject/Help/html 187 | 188 | # Click-Once directory 189 | publish/ 190 | 191 | # Publish Web Output 192 | *.[Pp]ublish.xml 193 | *.azurePubxml 194 | # Note: Comment the next line if you want to checkin your web deploy settings, 195 | # but database connection strings (with potential passwords) will be unencrypted 196 | *.pubxml 197 | *.publishproj 198 | 199 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 200 | # checkin your Azure Web App publish settings, but sensitive information contained 201 | # in these scripts will be unencrypted 202 | PublishScripts/ 203 | 204 | # NuGet Packages 205 | *.nupkg 206 | # NuGet Symbol Packages 207 | *.snupkg 208 | # The packages folder can be ignored because of Package Restore 209 | **/[Pp]ackages/* 210 | # except build/, which is used as an MSBuild target. 211 | !**/[Pp]ackages/build/ 212 | # Uncomment if necessary however generally it will be regenerated when needed 213 | #!**/[Pp]ackages/repositories.config 214 | # NuGet v3's project.json files produces more ignorable files 215 | *.nuget.props 216 | *.nuget.targets 217 | 218 | # Microsoft Azure Build Output 219 | csx/ 220 | *.build.csdef 221 | 222 | # Microsoft Azure Emulator 223 | ecf/ 224 | rcf/ 225 | 226 | # Windows Store app package directories and files 227 | AppPackages/ 228 | BundleArtifacts/ 229 | Package.StoreAssociation.xml 230 | _pkginfo.txt 231 | *.appx 232 | *.appxbundle 233 | *.appxupload 234 | 235 | # Visual Studio cache files 236 | # files ending in .cache can be ignored 237 | *.[Cc]ache 238 | # but keep track of directories ending in .cache 239 | !?*.[Cc]ache/ 240 | 241 | # Others 242 | ClientBin/ 243 | ~$* 244 | *~ 245 | *.dbmdl 246 | *.dbproj.schemaview 247 | *.jfm 248 | *.pfx 249 | *.publishsettings 250 | orleans.codegen.cs 251 | 252 | # Including strong name files can present a security risk 253 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 254 | #*.snk 255 | 256 | # Since there are multiple workflows, uncomment next line to ignore bower_components 257 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 258 | #bower_components/ 259 | 260 | # RIA/Silverlight projects 261 | Generated_Code/ 262 | 263 | # Backup & report files from converting an old project file 264 | # to a newer Visual Studio version. Backup files are not needed, 265 | # because we have git ;-) 266 | _UpgradeReport_Files/ 267 | Backup*/ 268 | UpgradeLog*.XML 269 | UpgradeLog*.htm 270 | ServiceFabricBackup/ 271 | *.rptproj.bak 272 | 273 | # SQL Server files 274 | *.mdf 275 | *.ldf 276 | *.ndf 277 | 278 | # Business Intelligence projects 279 | *.rdl.data 280 | *.bim.layout 281 | *.bim_*.settings 282 | *.rptproj.rsuser 283 | *- [Bb]ackup.rdl 284 | *- [Bb]ackup ([0-9]).rdl 285 | *- [Bb]ackup ([0-9][0-9]).rdl 286 | 287 | # Microsoft Fakes 288 | FakesAssemblies/ 289 | 290 | # GhostDoc plugin setting file 291 | *.GhostDoc.xml 292 | 293 | # Node.js Tools for Visual Studio 294 | .ntvs_analysis.dat 295 | node_modules/ 296 | 297 | # Visual Studio 6 build log 298 | *.plg 299 | 300 | # Visual Studio 6 workspace options file 301 | *.opt 302 | 303 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 304 | *.vbw 305 | 306 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 307 | *.vbp 308 | 309 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 310 | *.dsw 311 | *.dsp 312 | 313 | # Visual Studio 6 technical files 314 | *.ncb 315 | *.aps 316 | 317 | # Visual Studio LightSwitch build output 318 | **/*.HTMLClient/GeneratedArtifacts 319 | **/*.DesktopClient/GeneratedArtifacts 320 | **/*.DesktopClient/ModelManifest.xml 321 | **/*.Server/GeneratedArtifacts 322 | **/*.Server/ModelManifest.xml 323 | _Pvt_Extensions 324 | 325 | # Paket dependency manager 326 | .paket/paket.exe 327 | paket-files/ 328 | 329 | # FAKE - F# Make 330 | .fake/ 331 | 332 | # CodeRush personal settings 333 | .cr/personal 334 | 335 | # Python Tools for Visual Studio (PTVS) 336 | __pycache__/ 337 | *.pyc 338 | .pytest_cache/ 339 | .venv/ 340 | 341 | # Cake - Uncomment if you are using it 342 | # tools/** 343 | # !tools/packages.config 344 | 345 | # Tabs Studio 346 | *.tss 347 | 348 | # Telerik's JustMock configuration file 349 | *.jmconfig 350 | 351 | # BizTalk build output 352 | *.btp.cs 353 | *.btm.cs 354 | *.odx.cs 355 | *.xsd.cs 356 | 357 | # OpenCover UI analysis results 358 | OpenCover/ 359 | 360 | # Azure Stream Analytics local run output 361 | ASALocalRun/ 362 | 363 | # MSBuild Binary and Structured Log 364 | *.binlog 365 | 366 | # NVidia Nsight GPU debugger configuration file 367 | *.nvuser 368 | 369 | # MFractors (Xamarin productivity tool) working folder 370 | .mfractor/ 371 | 372 | # Local History for Visual Studio 373 | .localhistory/ 374 | 375 | # Visual Studio History (VSHistory) files 376 | .vshistory/ 377 | 378 | # BeatPulse healthcheck temp database 379 | healthchecksdb 380 | 381 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 382 | MigrationBackup/ 383 | 384 | # Ionide (cross platform F# VS Code tools) working folder 385 | .ionide/ 386 | 387 | # Fody - auto-generated XML schema 388 | FodyWeavers.xsd 389 | 390 | # VS Code files for those working on multiple tools 391 | .vscode/* 392 | !.vscode/settings.json 393 | !.vscode/tasks.json 394 | !.vscode/launch.json 395 | !.vscode/extensions.json 396 | *.code-workspace 397 | 398 | # Local History for Visual Studio Code 399 | .history/ 400 | 401 | # Windows Installer files from build outputs 402 | *.cab 403 | *.msi 404 | *.msix 405 | *.msm 406 | *.msp 407 | 408 | # JetBrains Rider 409 | *.sln.iml 410 | --------------------------------------------------------------------------------