├── .dockerignore ├── .gitattributes ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── __init__.py ├── classes │ ├── Auth.py │ ├── Chatgpt.py │ ├── Messages.py │ ├── Permissions.py │ ├── User.py │ └── __init__.py ├── components │ ├── __init__.py │ ├── auth │ │ ├── __init__.py │ │ ├── check_permissions.py │ │ ├── fastapi_auth.py │ │ └── jwt_token_handler.py │ ├── chat_gpt │ │ ├── __init__.py │ │ └── chatgpt_service.py │ ├── hash_password.py │ ├── initial_settings.py │ ├── logger.py │ └── message_dispatcher │ │ ├── __init__.py │ │ └── mail.py ├── db │ ├── __init__.py │ ├── mongoClient.py │ └── redisClient.py ├── main.py └── routers │ ├── __init__.py │ ├── auth.py │ ├── chatgpt.py │ ├── register.py │ ├── senders.py │ ├── settings │ ├── __init__.py │ └── messages.py │ └── users.py ├── docker-compose.yml ├── entrypoint.sh ├── generate_env.py ├── nextjs ├── .dockerignore ├── .gitignore ├── Dockerfile ├── jsconfig.json ├── next.config.mjs ├── package-lock.json ├── package.json └── src │ ├── api │ ├── auth │ │ └── auth-context.js │ ├── endpoints.js │ └── headers.js │ ├── components │ ├── confirmation-modal.js │ ├── error.js │ ├── loading.js │ ├── scroll-top-top.js │ └── warp-effects.js │ ├── hooks │ ├── use-authenticated-route.js │ └── use-popover.js │ ├── pages │ ├── 404.js │ ├── _app.js │ ├── _document.js │ ├── dashboard │ │ └── index.js │ ├── extensions │ │ └── chatgpt.js │ ├── index.js │ ├── login.js │ ├── reset-password │ │ └── [token].js │ ├── settings.js │ └── users.js │ ├── sections │ ├── auth │ │ ├── forgot-password.js │ │ ├── login-box.js │ │ ├── logout.js │ │ └── registration.js │ ├── extensions │ │ └── chat-gpt │ │ │ └── chat-component.js │ ├── footer │ │ └── footer.js │ ├── header │ │ ├── buttons │ │ │ └── settings-button.js │ │ ├── menu-items.js │ │ └── menu.js │ ├── profile │ │ └── user-profile.js │ ├── settings │ │ └── messages-settings.js │ └── users │ │ ├── user-dialog.js │ │ └── users-table.js │ ├── styles │ └── globals.css │ └── theme │ ├── dark-theme.js │ └── menu-icons.js ├── node_modules └── .package-lock.json ├── requirements.txt ├── screenshots ├── ChatGPT_EXT.png ├── Forgot-Password.png ├── Login.png ├── Register.png ├── Reset-Password.png └── youtube.png └── tests └── tests.py /.dockerignore: -------------------------------------------------------------------------------- 1 | # Excluding next.js frontend when creating the backend container. 2 | nextjs/ 3 | 4 | # Exclude Python bytecode files 5 | *.pyc 6 | 7 | # Exclude Python cache directories 8 | __pycache__ 9 | 10 | # Exclude development and editor-specific files 11 | .vscode/ 12 | .idea/ 13 | *.swp 14 | *.swo 15 | 16 | # Exclude Git related files and directories 17 | .git 18 | .gitignore 19 | 20 | # Exclude test and documentation files 21 | tests/ 22 | docs/ 23 | 24 | # Exclude any other files or directories that are not necessary for the runtime environment 25 | # Add additional exclusions as needed based on your project structure 26 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | chatgpt_credentials.env 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # Log files and folders 141 | logs/ 142 | *.log 143 | !/frontend/ 144 | 145 | 146 | # Other 147 | .idea/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Python runtime as a parent image 2 | FROM python:3.11-slim 3 | 4 | # Set environment variables 5 | # Don't write byte code files to disk 6 | ENV PYTHONDONTWRITEBYTECODE 1 7 | 8 | # Unbuffered output for easier container logging 9 | ENV PYTHONUNBUFFERED 1 10 | 11 | # Set the working directory in the container 12 | WORKDIR /app 13 | 14 | # Install dependencies 15 | COPY requirements.txt /app/ 16 | RUN pip install --no-cache-dir --upgrade pip && \ 17 | pip install --no-cache-dir -r requirements.txt 18 | 19 | # Copy the current directory contents into the container at /app 20 | COPY .. /app 21 | 22 | 23 | ## Copy the entrypoint script into the container 24 | #COPY entrypoint.sh /usr/local/bin/ 25 | # 26 | ## Make sure the script is executable 27 | #RUN chmod +x /usr/local/bin/entrypoint.sh 28 | # 29 | #ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] 30 | 31 | ## Command to run the application 32 | #CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] 33 | 34 | 35 | ## Run the container with multiple workers instead if needed. 36 | CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 George Khananaev 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyNextStack: A Full-Stack User Management System with FastAPI, Next.js, and MUI 2 | 3 | ### DEMO: [https://demo-pynextstack.reactatomics.com](https://demo-pynextstack.reactatomics.com) 4 | 5 | ``` 6 | Username: root 7 | Password: bringthemhome 8 | ``` 9 | 10 | ⚡️ **Looking for a fully serverless solution built with Next.js (no Python needed)?** 11 | Check out my **Modern Auth** template — simple, secure, and production-ready. 12 | 🔗 [github.com/georgekhananaev/modern-auth](https://github.com/georgekhananaev/modern-auth) 13 | 14 | For the DEMO, only one root user can be logged in at a time. Therefore, it is recommended to register your own user first and then promptly log in as the root user to change your role. Otherwise, you might cause each other to be disconnected while trying the demo. 15 | 16 | Please note that in the demo version, ChatGPT will not function as it is not connected to the API. Additionally, you have the option to modify the SMTP settings to suit your needs for testing the forgot-password functionality. It has been successfully tested with GMAIL. Later on, I will disable the edit functionality and add my own SMTP settings for your testing convenience. 17 | 18 | ## Overview 19 | 20 | PyNextStack is a full-stack system utilizing FastAPI with asynchronous capabilities on the backend and Next.js for the frontend, showcasing the robustness of Python in server-side development. This architecture provides a scalable, efficient solution that leverages FastAPI's high performance and ease of use for creating APIs, alongside Next.js for a reactive and server-side rendered user interface. The asynchronous nature of the backend ensures non-blocking operation, enhancing the system's ability to handle high volumes of requests simultaneously, which is ideal for real-time applications. This combination offers a modern, full-stack framework that is both powerful and developer-friendly, demonstrating the versatility of Python in web development. 21 | 22 | ## Key Features 23 | 24 | ### Security 25 | - **JWT Authentication**: Secure authentication mechanism using JWT to ensure that user actions are verified and secure. 26 | - **Protected API Documentation**: Access to API documentation is restricted, requiring authentication to prevent unauthorized use. 27 | - **Rate Limiting**: Defense against brute force attacks by limiting the number of login attempts. 28 | - **Data Encryption**: Encryption techniques are employed to securely store user data. 29 | 30 | ### User Management 31 | - **Registration and Login**: Efficient and secure processes for user registration and login. 32 | - **Profile Management**: Enables users to update their profiles and manage account settings. 33 | 34 | ### Interactivity and Notifications 35 | - **ChatGPT Integration**: Enhances user engagement with AI-driven chat support. 36 | - **Email Notifications**: Sends automated emails for actions such as registration and password resets using SMTP. 37 | 38 | ### Frontend Experience 39 | - **Next.js and MUI**: A modern, responsive UI built with Next.js and Material-UI for a seamless user experience. 40 | - **Responsive Design**: Ensures a consistent experience across various devices and screen sizes. 41 | 42 | ### Performance and Scalability 43 | - **Asynchronous Support**: Utilizes FastAPI's async features for efficient performance. 44 | - **Scalable Architecture**: Designed to handle growth in users and data smoothly. 45 | 46 | ### Logging and Monitoring 47 | - **Comprehensive Logging**: Detailed logging for user actions, system events, and errors. 48 | - **Real-Time Monitoring**: Tools and practices in place for monitoring application performance in real-time. 49 | 50 | ## Technologies Used 51 | 52 | - **FastAPI**: For building the high-performance API backend. 53 | - **Next.js**: The React framework for building the frontend. 54 | - **Material-UI (MUI)**: For designing the frontend with React UI components. 55 | - **MongoDB**: As the NoSQL database for user data. 56 | - **Redis**: For rate limiting and JWT token management. 57 | - **Docker**: For containerizing and deploying the application. 58 | - **Python-JOSE**: A library for JWT operations. 59 | - **SMTP Libraries**: For sending automated email notifications. 60 | 61 | ## Getting Started 62 | 63 | ### Prerequisites 64 | 65 | Before you begin, ensure you have met the following requirements: 66 | 67 | - **Docker**: This project is containerized with Docker, making it necessary to have Docker Desktop (for Windows or Mac) or Docker Engine (for Linux) installed on your system. To install Docker, follow the instructions on the [official Docker website](https://docs.docker.com/get-docker/). 68 | 69 | - **Python**: The project requires the latest version of Python for certain local scripts and integrations. To install Python, visit the [official Python website](https://www.python.org/downloads/) and download the latest version for your operating system. Ensure that Python is properly added to your system's PATH to allow for command-line execution. 70 | 71 | ## Installation 72 | Once you have Docker and Python installed, you're ready to proceed with the project setup. The next sections will guide you through configuring your development environment, running the project with Docker, and executing any necessary Python scripts or commands. 73 | 74 | **You can watch this:** 75 | [![IMAGE ALT TEXT HERE](/screenshots/youtube.png)](https://youtu.be/H2oYT-Ame9w) 76 | 77 | ### Clone the Repository 78 | ```shell 79 | git clone https://github.com/georgekhananaev/PyNextStack 80 | ``` 81 | 82 | ### Docker Installation for Full Deployment (4 Containers) 83 | 1. Create "chatgpt_credentials.env" file or revise the code in "generate_env.py". 84 | 85 | * Example of chatgpt_credentials.env: 86 | ``` 87 | open_ai_organization=org-your_openai_key 88 | open_ai_secret_key=sk-your_openai_key 89 | ``` 90 | 91 | 2. Run the Installation. 92 | 93 | * PowerShell / Linux (Option 1) 94 | ```shell 95 | python generate_env.py ; docker-compose build --no-cache ; docker-compose up -d 96 | ``` 97 | * CMD (Option 2) 98 | ```shell 99 | python generate_env.py && docker-compose build --no-cache && docker-compose up -d 100 | ``` 101 | * Manual (Option 3) 102 | ```shell 103 | python generate_env.py 104 | ``` 105 | ```shell 106 | docker-compose build --no-cache 107 | ``` 108 | ```shell 109 | docker-compose up -d 110 | ``` 111 | 112 | ## Uninstall 113 | 114 | ```shell 115 | docker-compose down -v 116 | ``` 117 | 118 | ### If you want to start just the backend (FastAPI) 119 | Just the FastAPI server. You must start MongoDB server, Redis server first. Change the username and password URI in the .env file above. 120 | 121 |
122 | Create a .env File or Run `python generate_env.py` 123 |

124 | 125 | ```text 126 | # mongodb connection 127 | mongodb_server=localhost 128 | mongodb_port=27017 129 | mongodb_username=bringthemhome 130 | mongodb_password=bringthemhome 131 | 132 | # fastapi 133 | fastapi_ui_username=bringthemhome 134 | fastapi_ui_password=bringthemhome 135 | jwt_secret_key=bringthemhome 136 | static_bearer_secret_key=bringthemhome 137 | algorithm=HS256 138 | 139 | # chatgpt 140 | open_ai_organization=org-your_openai_key 141 | open_ai_secret_key=sk-your_openai_key 142 | 143 | # default root user 144 | owner_username=root 145 | owner_password=bringthemhome 146 | owner_email=israel@israeli.com 147 | 148 | # Initial email settings located in app/components/initial settings.py 149 | ``` 150 | 151 | Please note: MongoDB URI should be "localhost" if you're running it locally, or "mongodb" if you're running it inside a Docker container. 152 |

153 |
154 | 155 | * Update PIP && Install requirements.txt 156 | ```shell 157 | python.exe -m pip install --upgrade pip 158 | ``` 159 | ```shell 160 | pip install -r requirements.txt 161 | ``` 162 | 163 | * Start FastAPI server with Uvicorn 164 | ```shell 165 | uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload 166 | ``` 167 | 168 | ### If you want to run the frontend (Next.js) 169 | You need to have Node.js installed on your machine. 170 | Visit https://nodejs.org/ to download and install the latest version. 171 | 172 |
173 | Create a .env File If Necessary; Otherwise, Default Settings Are Loaded 174 |

175 | 176 | ```text 177 | # Set the URL for your backend here 178 | NEXT_PUBLIC_API_BASE_URL=http://localhost:8000/api/v1 179 | 180 | # The default value is 'bringthemhome'. Ensure this matches the 'static_bearer_secret_key' set in your backend. 181 | NEXT_PUBLIC_API_KEY=static_bearer_secret_key 182 | ``` 183 | 184 | Please note: If you are running MongoDB locally, the URI should be set to "localhost". If you are running MongoDB inside a Docker container, the URI should be set to "mongodb". 185 |

186 |
187 | 188 | * For Development: 189 | ```shell 190 | npm install 191 | ``` 192 | ```shell 193 | npm run dev 194 | ``` 195 | 196 | * For Production: 197 | ```shell 198 | npm run build 199 | ``` 200 | ```shell 201 | npm start 202 | ``` 203 | 204 | ## Usage 205 | 206 | - Frontend: [http://localhost:3000](http://localhost:3000) 207 | ``` 208 | Username: root 209 | Password: bringthemhome 210 | ``` 211 | * Access the API documentation at http://localhost:8000/docs. You can obtain a token by entering your username and password from the text box above. 212 | * Please note that the Swagger UI is also password-protected, and it will temporarily block access if the password is entered incorrectly more than five times, for a duration of five minutes. 213 | ``` 214 | Username: bringthemhome 215 | Password: bringthemhome 216 | ``` 217 | 218 | ## Security Practices 219 | This application implements advanced security practices including password hashing, token validation, rate limiting, and secure API documentation access. 220 | 221 | ## License 222 | This project is licensed under the MIT License - see the LICENSE.md file for details. 223 | 224 | ## Credits 225 | - Developed by George Khananaev. 226 | - Thanks to the FastAPI, MongoDB, Redis, and Docker communities for support and resources. 227 | 228 | ## Additional Requests 229 | 230 | If you have ideas on how we can grow it into something greater, please share your suggestions. Additionally, if you have any special requests or unique features you'd like to see implemented, feel free to include those as well. 231 | 232 | ## Goals 233 | 234 | **Upon reaching 100 stars, the project will incorporate the following features:** 235 | 236 | 1. Firebase authentication support will be integrated. 237 | 2. Two-Factor Authentication will be implemented. 238 | 3. SMS and WhatsApp support will be added to the backend with TWILIO. 239 | 4. File handling capabilities, utilizing either Google Storage or AWS. 240 | 5. Enhanced email handling, including functionalities such as Inbox, Outgoing, Draft, etc. 241 | 242 | ## Support Me 243 | 244 | If you find my work helpful, consider supporting me by buying me a coffee at [Buy Me A Coffee](https://www.buymeacoffee.com/georgekhananaev). 245 | 246 | Your support helps me continue to create and maintain useful projects. 247 | 248 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/georgekhananaev) 249 | 250 | Thank you! -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgekhananaev/PyNextStack/7546865fa1cd8b46140194813d9dae3e7310d925/app/__init__.py -------------------------------------------------------------------------------- /app/classes/Auth.py: -------------------------------------------------------------------------------- 1 | # from typing import Optional 2 | from pydantic import BaseModel 3 | 4 | 5 | class SimpleAuthForm(BaseModel): 6 | username: str 7 | password: str 8 | # scope: Optional[str] = None # Make scope optional 9 | -------------------------------------------------------------------------------- /app/classes/Chatgpt.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class ChatGptModelEnum(str, Enum): 5 | gpt_3_5_turbo = "gpt-3.5-turbo" 6 | gpt_4 = "gpt-4" 7 | # Add more models as needed 8 | -------------------------------------------------------------------------------- /app/classes/Messages.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel, EmailStr 4 | 5 | 6 | class SmtpModel(BaseModel): 7 | active: Optional[bool] = None 8 | server: Optional[str] = None 9 | port: Optional[int] = None 10 | user: Optional[str] = None 11 | password: Optional[str] = None 12 | system_email: Optional[EmailStr] = None 13 | 14 | 15 | class WhatsappModel(BaseModel): 16 | active: Optional[bool] = None 17 | account_sid: Optional[str] = None 18 | auth_token: Optional[str] = None 19 | from_number: Optional[str] = None 20 | 21 | 22 | class SmsModel(BaseModel): 23 | active: Optional[bool] = None 24 | provider: Optional[str] = None 25 | api_key: Optional[str] = None 26 | from_number: Optional[str] = None 27 | 28 | 29 | class MessagesConfigModel(BaseModel): 30 | smtp: Optional[SmtpModel] = None 31 | whatsapp: Optional[WhatsappModel] = None 32 | sms: Optional[SmsModel] = None 33 | -------------------------------------------------------------------------------- /app/classes/Permissions.py: -------------------------------------------------------------------------------- 1 | # permissions.py 2 | 3 | class RolePermissions: 4 | role_permissions_map = { 5 | "owner": ["read", "write", "delete", "edit"], 6 | "admin": ["read", "write", "edit"], 7 | "user": ["read"] 8 | } 9 | 10 | 11 | class HTTPMethodPermissions: 12 | method_permission_map = { 13 | "GET": "read", 14 | "POST": "write", 15 | "PUT": "edit", 16 | "DELETE": "delete", 17 | } 18 | 19 | @staticmethod 20 | def get_permission_for_method(method: str) -> str: 21 | """Retrieve the permission required for a given HTTP method.""" 22 | return HTTPMethodPermissions.method_permission_map.get(method, "") 23 | -------------------------------------------------------------------------------- /app/classes/User.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel, Field, EmailStr 5 | 6 | 7 | # Define roles 8 | class Role(str, Enum): 9 | owner = "owner" 10 | admin = "admin" 11 | user = "user" 12 | 13 | 14 | class UserBase(BaseModel): 15 | username: str # User's username 16 | email: EmailStr # User's email, validated to be a proper email format 17 | full_name: str # User's full name 18 | disabled: Optional[bool] = None # Optional field to disable the user 19 | role: Role = Role.user # Default role 20 | 21 | class UpdateUser(BaseModel): 22 | username: Optional[str] = None # Now optional 23 | email: Optional[EmailStr] = None # Now optional, but still validated if provided 24 | full_name: Optional[str] = None # Now optional 25 | disabled: Optional[bool] = None # Remains optional 26 | role: Optional[Role] = Role.user # Optional, with a default value if not provided 27 | password: Optional[str] = None 28 | 29 | class UserCreate(UserBase): 30 | password: Optional[str] # Password field for user creation 31 | 32 | 33 | class User(UserBase): 34 | id: Optional[str] = Field(default=None, alias="_id") # User ID, mapping MongoDB's '_id' 35 | 36 | @classmethod 37 | def from_mongo(cls, data: dict): 38 | if "_id" in data: 39 | data["_id"] = str(data["_id"]) # Convert MongoDB ObjectId to string 40 | return cls(**data) # Create User instance with modified data 41 | 42 | class Config: 43 | json_schema_extra = { 44 | "example": { 45 | "username": "israel", 46 | "email": "israel@example.com", 47 | "full_name": "israel israeli", 48 | "disabled": False, 49 | } 50 | } 51 | from_attributes = True # Enable ORM mode for compatibility with databases 52 | populate_by_name = True # Allows field population by name, useful for fields with aliases 53 | 54 | 55 | class UserRegistration(BaseModel): 56 | username: str 57 | email: EmailStr 58 | full_name: str 59 | password: str 60 | 61 | class Config: 62 | json_schema_extra = { 63 | "example": { 64 | "username": "newuser", 65 | "email": "newuser@example.com", 66 | "full_name": "New User", 67 | "password": "securepassword123", 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/classes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgekhananaev/PyNextStack/7546865fa1cd8b46140194813d9dae3e7310d925/app/classes/__init__.py -------------------------------------------------------------------------------- /app/components/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgekhananaev/PyNextStack/7546865fa1cd8b46140194813d9dae3e7310d925/app/components/__init__.py -------------------------------------------------------------------------------- /app/components/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgekhananaev/PyNextStack/7546865fa1cd8b46140194813d9dae3e7310d925/app/components/auth/__init__.py -------------------------------------------------------------------------------- /app/components/auth/check_permissions.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends, HTTPException, Request 2 | from starlette.status import HTTP_403_FORBIDDEN, HTTP_401_UNAUTHORIZED 3 | 4 | from app.classes.Permissions import RolePermissions, HTTPMethodPermissions 5 | from app.components.auth.jwt_token_handler import get_jwt_username 6 | from app.components.logger import logger 7 | from app.db.mongoClient import async_database 8 | 9 | 10 | async def check_permissions(request: Request, username: str = Depends(get_jwt_username)): 11 | """ 12 | Check if the user has the required permissions to access the route. 13 | """ 14 | try: 15 | user_collection = async_database.users 16 | user = await user_collection.find_one({"username": username}) 17 | if not user: 18 | logger.error(f"User not found: {username}") 19 | raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="User not found.") 20 | 21 | if user.get("disabled", False): 22 | logger.warning(f"Access denied for disabled account: {username}") 23 | raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Account is disabled.") 24 | 25 | user_role = user.get("role") 26 | 27 | # Use the new classes for permission checking 28 | required_permission = HTTPMethodPermissions.get_permission_for_method(request.method) 29 | 30 | # Check if the user's role includes the required permission 31 | user_permissions = RolePermissions.role_permissions_map.get(user_role, []) 32 | if required_permission not in user_permissions: 33 | logger.warning(f"Permission denied: {username} attempted to {request.method}") 34 | raise HTTPException(status_code=HTTP_403_FORBIDDEN, 35 | detail="You don't have permission to perform this action.") 36 | logger.info(f"Permission granted: {username} accessed {request.url.path} with method {request.method}") 37 | except HTTPException as e: 38 | # Log the error and re-raise the exception 39 | logger.error(f"Error during permission check for user {username}: {str(e.detail)}") 40 | raise 41 | -------------------------------------------------------------------------------- /app/components/auth/fastapi_auth.py: -------------------------------------------------------------------------------- 1 | import os 2 | import secrets 3 | from datetime import datetime, timedelta 4 | 5 | from dotenv import load_dotenv 6 | from fastapi import HTTPException, Depends, security, status, Request 7 | from fastapi.security import HTTPBasicCredentials, HTTPBasic 8 | 9 | # Load environment variables from .env file 10 | load_dotenv() 11 | SECRET_KEY = os.environ["static_bearer_secret_key"] # loading bearer_secret_key from.env file 12 | 13 | # Create an instance of HTTPBearer 14 | http_bearer = security.HTTPBearer() 15 | security_basic = HTTPBasic() 16 | 17 | 18 | async def get_secret_key(security_payload: security.HTTPAuthorizationCredentials = Depends(http_bearer)): 19 | """ 20 | This function is used to get the secret key from the authorization header. 21 | :param security_payload: 22 | :return: 23 | """ 24 | authorization = security_payload.credentials 25 | if not authorization or SECRET_KEY not in authorization: 26 | raise HTTPException(status_code=403, detail="Unauthorized") 27 | return authorization 28 | 29 | 30 | async def get_login_attempts(username: str, request: Request): 31 | redis_client = request.app.state.redis 32 | attempts = await redis_client.get(f"{username}:attempts") 33 | return int(attempts) if attempts else 0 34 | 35 | 36 | async def get_last_attempt_time(username: str, request: Request): 37 | """ 38 | This function is used to get the last login attempt time. 39 | """ 40 | redis_client = request.app.state.redis 41 | 42 | last_time = await redis_client.get(f"{username}:last_attempt") # This should be awaited 43 | if last_time: 44 | return datetime.fromtimestamp(float(last_time)) 45 | return None 46 | 47 | 48 | async def set_failed_login(username: str, attempts: int, last_attempt_time: datetime, request: Request): 49 | """ 50 | This function is used to set the number of failed login attempts and the last login attempt time. 51 | """ 52 | redis_client = request.app.state.redis 53 | 54 | await redis_client.set(f"{username}:attempts", attempts, ex=300) # 5 minutes expiration 55 | await redis_client.set(f"{username}:last_attempt", last_attempt_time.timestamp(), ex=300) # 5 minutes expiration 56 | 57 | 58 | async def reset_login_attempts(username: str, request: Request): 59 | """ 60 | This function is used to reset the number of failed login attempts and the last login attempt time. 61 | """ 62 | redis_client = request.app.state.redis 63 | await redis_client.delete(f"{username}:attempts", f"{username}:last_attempt") 64 | 65 | 66 | async def verify_credentials(request: Request, credentials: HTTPBasicCredentials = Depends(security_basic)): 67 | username = credentials.username 68 | current_time = datetime.now() 69 | 70 | attempts = await get_login_attempts(username, request) 71 | last_attempt_time = await get_last_attempt_time(username, request) 72 | if attempts >= 5 and last_attempt_time and (current_time - last_attempt_time) < timedelta(minutes=5): 73 | raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, 74 | detail="Too many login attempts. Please try again later.") 75 | 76 | correct_username = secrets.compare_digest(credentials.username, os.environ["fastapi_ui_username"]) 77 | correct_password = secrets.compare_digest(credentials.password, os.environ["fastapi_ui_password"]) 78 | 79 | if not (correct_username and correct_password): 80 | await set_failed_login(username, attempts + 1, current_time, request) 81 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password") 82 | 83 | await reset_login_attempts(username, request) 84 | 85 | return credentials -------------------------------------------------------------------------------- /app/components/auth/jwt_token_handler.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime, timedelta 3 | 4 | import jwt 5 | from dotenv import load_dotenv 6 | from fastapi import HTTPException, Header, Request 7 | 8 | # from app.db.mongoClient import database 9 | 10 | load_dotenv() 11 | 12 | # Secret key for JWT encoding and decoding. In a real app, keep this secret and safe! 13 | SECRET_KEY = os.environ["jwt_secret_key"] # Ensure this is corrected 14 | ALGORITHM = os.environ["algorithm"] 15 | 16 | 17 | async def create_jwt_access_token(request: Request, data: dict, expires_delta: timedelta = None): 18 | to_encode = data.copy() # data copy is required for jwt.encode() to work 19 | user_id = data["sub"] # Get the user_id from the payload 20 | redis_client = request.app.state.redis 21 | 22 | if expires_delta: 23 | expire = datetime.utcnow() + expires_delta 24 | else: 25 | expire = datetime.utcnow() + timedelta(minutes=120) # Token expires in 120 minutes by default 26 | to_encode.update({"exp": expire}) 27 | 28 | encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) 29 | 30 | # Token expiration in seconds (2 days) 31 | token_expiration_seconds = 48 * 60 * 60 32 | 33 | # Key for storing the mapping of user_id to their token 34 | user_key = f"USER_{user_id}_API_KEY" 35 | 36 | # Check if this user already has a token 37 | existing_api_key = await redis_client.get(user_key) 38 | if existing_api_key: 39 | # Delete the old token to invalidate it 40 | await redis_client.delete(f"API_KEY_{existing_api_key}") 41 | 42 | # Store the new token in Redis 43 | api_key = f"API_KEY_{encoded_jwt}" # Unique key for each user's token 44 | await redis_client.setex(api_key, token_expiration_seconds, user_id) # Storing user_id for reference, if needed 45 | 46 | # Update the user's current active token mapping 47 | await redis_client.setex(user_key, token_expiration_seconds, encoded_jwt) 48 | 49 | return encoded_jwt 50 | 51 | 52 | async def get_jwt_secret_key(request: Request, api_key: str = Header(...)): 53 | redis_client = request.app.state.redis 54 | 55 | token_key = f"API_KEY_{api_key}" 56 | token_exists = await redis_client.exists(token_key) 57 | if not token_exists: 58 | raise HTTPException(status_code=401, detail="API key is invalid or has expired") 59 | # If the key exists, you might want to return something or just let the request pass 60 | return api_key 61 | 62 | 63 | async def get_user_id_from_jwt(token: str): 64 | """ 65 | Extracts the user ID from the provided JWT token. 66 | :param token: 67 | :return: 68 | """ 69 | try: 70 | # Decode the JWT without verification just to extract user ID 71 | payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM], options={"verify_signature": False}) 72 | user_id = payload.get("sub") 73 | if not user_id: 74 | raise HTTPException(status_code=400, detail="Invalid JWT token: No user ID (sub) present.") 75 | return user_id 76 | except jwt.PyJWTError as e: 77 | # Handle decoding errors (e.g., token is malformed) 78 | raise HTTPException(status_code=400, detail=f"Invalid JWT token: {e}") 79 | 80 | 81 | async def get_username_from_api_key(request: Request, api_key: str = Header(...)): 82 | """ 83 | Extracts the username (user_id) from the provided API key (JWT token). 84 | Optionally verifies if the token is still valid and not expired. 85 | """ 86 | redis_client = request.app.state.redis 87 | 88 | # Extract user_id from the JWT token 89 | user_id = get_user_id_from_jwt(api_key) 90 | 91 | # Here you might want to verify if the token is still valid by checking 92 | # if it exists in Redis or if it's not expired, depending on your application logic. 93 | 94 | # Assuming redisClient is already connected and setup 95 | token_exists = await redis_client.exists(f"API_KEY_{api_key}") 96 | if not token_exists: 97 | raise HTTPException(status_code=401, detail="API key is invalid or has expired") 98 | 99 | # Return the user_id if everything is valid 100 | return user_id 101 | 102 | 103 | async def get_jwt_username(api_key: str = Header(...)): 104 | try: 105 | payload = jwt.decode(api_key, SECRET_KEY, algorithms=[ALGORITHM]) 106 | username = payload.get("sub", None) 107 | if not username: 108 | raise HTTPException(status_code=401, detail="Invalid JWT token: Username missing") 109 | return username 110 | except jwt.PyJWTError: 111 | raise HTTPException(status_code=401, detail="Invalid JWT token") -------------------------------------------------------------------------------- /app/components/chat_gpt/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgekhananaev/PyNextStack/7546865fa1cd8b46140194813d9dae3e7310d925/app/components/chat_gpt/__init__.py -------------------------------------------------------------------------------- /app/components/chat_gpt/chatgpt_service.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import httpx 5 | from dotenv import load_dotenv 6 | 7 | load_dotenv() 8 | API_KEY = os.getenv("open_ai_secret_key") 9 | 10 | logging.basicConfig(level=logging.INFO) 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def ask_chatgpt_with_context(gpt_question: str, model: str = "gpt-3.5-turbo"): 15 | headers = { 16 | "Authorization": f"Bearer {API_KEY}" 17 | } 18 | 19 | payload = { 20 | "model": model, 21 | "messages": [ 22 | {"role": "user", "content": gpt_question} 23 | ] 24 | } 25 | 26 | try: 27 | with httpx.Client() as client: 28 | response = client.post("https://api.openai.com/v1/chat/completions", headers=headers, json=payload) 29 | response.raise_for_status() 30 | data = response.json() 31 | return ''.join(choice['message']['content'] for choice in data['choices'] if 32 | 'message' in choice and 'content' in choice['message']) 33 | except httpx.HTTPStatusError as e: 34 | logger.error(f"HTTP error occurred: {e}") 35 | except httpx.RequestError as e: 36 | logger.error(f"Request error occurred: {e}") 37 | except Exception as e: 38 | logger.error(f"An unexpected error occurred: {e}") 39 | return "Error: Unable to fetch response." 40 | 41 | # Example usage 42 | if __name__ == "__main__": 43 | question = "Which nation founded Jerusalem and has the strongest connection with it throughout known history?" 44 | print(ask_chatgpt_with_context(question)) 45 | -------------------------------------------------------------------------------- /app/components/hash_password.py: -------------------------------------------------------------------------------- 1 | from passlib.context import CryptContext 2 | 3 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 4 | 5 | 6 | def hash_password(password: str) -> str: 7 | """ 8 | Hash the password 9 | :param password: 10 | :return: 11 | """ 12 | return pwd_context.hash(password) 13 | -------------------------------------------------------------------------------- /app/components/initial_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pymongo 4 | from bson import ObjectId 5 | from pymongo.errors import DuplicateKeyError 6 | from dotenv import load_dotenv 7 | 8 | from app.classes.User import UserCreate 9 | from app.components.logger import logger 10 | from app.db.mongoClient import async_database 11 | from app.routers.users import create_user 12 | 13 | load_dotenv() # loading environment variables 14 | 15 | 16 | async def check_key_not_expired(redis_client, key): 17 | """ 18 | Check if a given key exists and thus has not expired. 19 | """ 20 | # Attempt to get the value for the specified key 21 | value = redis_client.get(key) 22 | 23 | # If the value exists, the key has not expired 24 | if value is not None: 25 | return True 26 | else: 27 | return False 28 | 29 | 30 | async def create_indexes(): 31 | await async_database.users.create_index("email", unique=True) 32 | await async_database.users.create_index("username", unique=True) 33 | 34 | async def create_owner(): 35 | # Fetch owner's email and username from environment variables 36 | owner_email = os.getenv("owner_email") 37 | owner_username = os.getenv("owner_username") 38 | 39 | # Assuming async_database.users is your collection from an async database 40 | user_collection = async_database.users 41 | 42 | # Check if an owner with the same email already exists 43 | if await user_collection.find_one({"email": owner_email}): 44 | logger.info(f"An owner with email {owner_email} already exists, skipping creation.") 45 | return 46 | 47 | # Check if an owner with the same username already exists 48 | if await user_collection.find_one({"username": owner_username}): 49 | logger.info(f"An owner with username {owner_username} already exists, skipping creation.") 50 | return 51 | 52 | # Define the admin user with details from environment variables 53 | admin_user = { 54 | "username": owner_username, 55 | "email": owner_email, 56 | "full_name": "Site Owner", 57 | "disabled": False, 58 | "password": os.getenv("owner_password"), # Assuming the password is also stored in env variables 59 | "role": "owner" 60 | } 61 | 62 | # Create the user with the specified admin user details 63 | try: 64 | await create_user(UserCreate(**admin_user), "system_init") 65 | logger.info("Owner created successfully.") 66 | except pymongo.errors.DuplicateKeyError: 67 | logger.info("Owner already exists, skipping creation.") 68 | 69 | logger.info("Owner created successfully.") 70 | 71 | 72 | async def initialize_message_settings(): 73 | settings_collection = async_database.settings # Assuming a collection named 'settings' 74 | 75 | # Convert the string ID to an ObjectId 76 | settings_id = ObjectId("65fdaaca4f94194ff730d3be") 77 | 78 | # Check if settings already exist to avoid duplication 79 | existing_settings = await settings_collection.find_one({"_id": settings_id}) 80 | if existing_settings: 81 | logger.info("Message settings already initialized, skipping.") 82 | return 83 | 84 | # Define the initial settings document with ObjectId for _id 85 | initial_settings = { 86 | "_id": settings_id, 87 | "smtp": { 88 | "active": False, 89 | "server": "smtp.gmail.com", 90 | "port": 587, 91 | "user": "your_email@gmail.com", 92 | "password": "your_password", 93 | "system_email": "your_email@gmail.com" 94 | }, 95 | "whatsapp": { 96 | "active": False, 97 | "account_sid": "your_account_sid", 98 | "auth_token": "your_auth_token", 99 | "from_number": "+1234567890" 100 | }, 101 | "sms": { 102 | "active": False, 103 | "provider": "Twilio", 104 | "api_key": "your_api_key", 105 | "from_number": "+1234567890" 106 | } 107 | } 108 | 109 | # Insert the initial settings document into the database 110 | await settings_collection.insert_one(initial_settings) 111 | logger.info("Initial settings have been successfully set up.") 112 | -------------------------------------------------------------------------------- /app/components/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from datetime import datetime 4 | from logging.handlers import BaseRotatingHandler 5 | 6 | 7 | class DailyRotatingFileHandler(BaseRotatingHandler): 8 | """Rotating file handler that rotates the file every day.""" 9 | 10 | def __init__(self, dir_name, base_filename, encoding=None): 11 | """ 12 | :param dir_name: Directory to store monthly folders 13 | """ 14 | self.dir_name = dir_name # Directory to store monthly folders 15 | self.base_filename = base_filename # Base name for log files 16 | self.encoding = encoding 17 | self.current_date = None 18 | filename = self._get_filename() # Initial filename 19 | os.makedirs(os.path.dirname(filename), exist_ok=True) # Ensure directory exists 20 | BaseRotatingHandler.__init__(self, filename, 'a', encoding, delay=True) 21 | 22 | def _get_filename(self): 23 | """Generate a filename based on the current date.""" 24 | now = datetime.now() 25 | month_dir = now.strftime('%Y-%m') # Format: YYYY-MM 26 | date_str = now.strftime('%Y-%m-%d') # Format: YYYY-MM-DD 27 | self.current_date = date_str 28 | filename = f"{self.base_filename}_{date_str}.log" 29 | full_path = os.path.join(self.dir_name, month_dir, filename) 30 | return full_path 31 | 32 | def shouldRollover(self, record): # noqa 33 | """Determine if we should rollover to a new file.""" 34 | now = datetime.now().strftime('%Y-%m-%d') 35 | return self.current_date != now # True if the date has changed 36 | 37 | def doRollover(self): # noqa 38 | """Roll over to a new log file.""" 39 | self.stream.close() 40 | self.baseFilename = self._get_filename() 41 | self.stream = self._open() 42 | 43 | 44 | # Create base 'logs' directory if it doesn't exist 45 | base_log_dir = 'logs' 46 | if not os.path.exists(base_log_dir): 47 | os.makedirs(base_log_dir) 48 | 49 | # Configure logging 50 | logging.basicConfig(level=logging.INFO) 51 | logger = logging.getLogger(__name__) 52 | 53 | # Use the custom handler 54 | handler = DailyRotatingFileHandler(base_log_dir, 'fastapi', encoding='utf-8') 55 | formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') # noqa 56 | handler.setFormatter(formatter) 57 | logger.addHandler(handler) 58 | 59 | # Ensure that logging of each log level gets propagated to the root logger 60 | logger.propagate = True 61 | -------------------------------------------------------------------------------- /app/components/message_dispatcher/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgekhananaev/PyNextStack/7546865fa1cd8b46140194813d9dae3e7310d925/app/components/message_dispatcher/__init__.py -------------------------------------------------------------------------------- /app/components/message_dispatcher/mail.py: -------------------------------------------------------------------------------- 1 | import smtplib 2 | from email import encoders 3 | from email.mime.base import MIMEBase 4 | from email.mime.multipart import MIMEMultipart 5 | from email.mime.text import MIMEText 6 | from typing import List 7 | 8 | from aiosmtplib import SMTP, SMTPException 9 | from bson import ObjectId 10 | from fastapi import UploadFile, HTTPException 11 | 12 | from app.components.logger import logger 13 | from app.db.mongoClient import async_mdb_client, async_database 14 | 15 | # mongo connection 16 | config_collection = async_database.settings 17 | emails_collection = async_mdb_client.messages.emails_sent 18 | 19 | 20 | async def get_config_data(config_key: str): 21 | config_id = "65fdaaca4f94194ff730d3be" 22 | config = await config_collection.find_one({"_id": ObjectId(config_id)}) 23 | if not config: 24 | raise HTTPException(status_code=404, detail="Config not found") 25 | return config.get(config_key) 26 | 27 | 28 | async def send_email_and_save(subject: str, body: str, to_emails: List[str], files: List[UploadFile] = []): # noqa 29 | smtp_config = await get_config_data("smtp") 30 | smtp = None # Declare smtp here 31 | 32 | # Prepare the email message 33 | msg = MIMEMultipart() 34 | msg['Subject'] = subject 35 | msg['From'] = smtp_config["system_email"] 36 | msg['To'] = ', '.join(to_emails) 37 | msg.attach(MIMEText(body, 'plain')) 38 | 39 | # Attach files 40 | for file in files: 41 | part = MIMEBase('application', "octet-stream") 42 | content = await file.read() # Ensure you read the content here 43 | part.set_payload(content) 44 | encoders.encode_base64(part) 45 | part.add_header('Content-Disposition', f'attachment; filename={file.filename}') 46 | msg.attach(part) 47 | 48 | try: 49 | smtp = SMTP(hostname=smtp_config["server"], port=smtp_config["port"]) 50 | if smtp_config["port"] == 587: 51 | await smtp.connect(start_tls=True) 52 | else: 53 | # Assuming SSL from the start for port 465 or other SSL ports 54 | await smtp.connect(use_tls=True) 55 | 56 | await smtp.login(smtp_config["user"], smtp_config["password"]) 57 | await smtp.send_message(msg) 58 | 59 | logger.info("Email sent successfully.") 60 | except SMTPException as e: 61 | logger.error(f"Failed to send email. Error: {e}") 62 | raise 63 | finally: 64 | await smtp.quit() 65 | 66 | # Save email details in MongoDB after successful sending 67 | email_data = { 68 | "subject": subject, 69 | "body": body, 70 | "to_emails": to_emails, 71 | "attachments": [file.filename for file in files], 72 | } 73 | await emails_collection.insert_one(email_data) 74 | logger.info("Email data saved successfully in MongoDB.") 75 | return {"message": "Email sent and saved successfully"} 76 | 77 | 78 | async def test_email_connection(): 79 | smtp_config = await get_config_data("smtp") 80 | smtp = None # Declare smtp here 81 | 82 | msg = MIMEMultipart() 83 | msg['Subject'] = "Test Email Connection" 84 | msg['From'] = smtp_config["system_email"] 85 | msg['To'] = "your_email@gmail.com" 86 | msg.attach(MIMEText("This is a test email to verify SMTP configuration.", 'plain')) 87 | 88 | try: 89 | smtp = SMTP(hostname=smtp_config["server"], port=smtp_config["port"]) 90 | if smtp_config["port"] == 587: 91 | await smtp.connect(start_tls=True) 92 | else: 93 | await smtp.connect(use_tls=True) # For SSL from the beginning without STARTTLS 94 | await smtp.login(smtp_config["user"], smtp_config["password"]) 95 | await smtp.send_message(msg) 96 | 97 | # If email sent successfully, update 'active' to True 98 | await config_collection.update_one( 99 | {"_id": ObjectId("65fdaaca4f94194ff730d3be")}, 100 | {"$set": {"smtp.active": True}} 101 | ) 102 | logger.info("Test email sent successfully. SMTP status set to active.") 103 | return {"message": "Test email sent successfully. SMTP status set to active."} 104 | 105 | except SMTPException as e: 106 | # If sending fails, update 'active' to False 107 | await config_collection.update_one( 108 | {"_id": ObjectId("65fdaaca4f94194ff730d3be")}, 109 | {"$set": {"smtp.active": False}} 110 | ) 111 | logger.error(f"Failed to send test email. SMTP status set to inactive. Error: {str(e)}") 112 | return {"message": f"Failed to send test email. SMTP status set to inactive. Error: {str(e)}"} 113 | 114 | finally: 115 | await smtp.quit() 116 | 117 | 118 | async def main(): 119 | await test_email_connection() 120 | 121 | 122 | def manual_send_test_email(smtp_server, port, sender_email, receiver_email, password, subject, body): 123 | """ 124 | Send a test email using specified SMTP server settings. 125 | 126 | Parameters: 127 | smtp_server (str): SMTP server host name. 128 | port (int): SMTP server port number. 129 | sender_email (str): The email address sending the email. 130 | receiver_email (str): The email address receiving the email. 131 | password (str): Password or app-specific password for the sender's email account. 132 | subject (str): Subject line of the email. 133 | body (str): Body content of the email. 134 | """ 135 | 136 | # Create a MIMEText object to represent the email. 137 | msg = MIMEMultipart() 138 | msg['From'] = sender_email 139 | msg['To'] = receiver_email 140 | msg['Subject'] = subject 141 | msg.attach(MIMEText(body, 'plain')) 142 | 143 | try: 144 | # Choose the right connection type based on the port 145 | if port == 465: 146 | # Use SMTP_SSL for a direct SSL connection 147 | server = smtplib.SMTP_SSL(smtp_server, port) 148 | else: 149 | # Use SMTP and upgrade to SSL with starttls 150 | server = smtplib.SMTP(smtp_server, port) 151 | server.starttls() # Upgrade the connection to encrypted TLS 152 | 153 | # Log in to the server and send the email 154 | server.login(sender_email, password) 155 | server.send_message(msg) # Using send_message simplifies the API usage 156 | server.quit() 157 | 158 | logger.info("Email sent successfully!") 159 | except Exception as e: 160 | logger.error(f"Failed to send email. Error: {e}") 161 | 162 | 163 | if __name__ == "__main__": 164 | manual_send_test_email("smtp.gmail.com", "465", "mhgfallback@gmail.com", "mhgfallback@gmail.com", "password", "Test Email", 165 | "This is a test email.") -------------------------------------------------------------------------------- /app/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgekhananaev/PyNextStack/7546865fa1cd8b46140194813d9dae3e7310d925/app/db/__init__.py -------------------------------------------------------------------------------- /app/db/mongoClient.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | 4 | from dotenv import load_dotenv 5 | from motor.motor_asyncio import AsyncIOMotorClient 6 | 7 | from app.components.logger import logger 8 | 9 | load_dotenv() # loading environment variables 10 | 11 | MONGODB_USER = os.getenv("mongodb_username") 12 | MONGODB_PASS = os.getenv("mongodb_password") 13 | MONGODB_SERVER = os.getenv("mongodb_server") 14 | MONGODB_PORT = os.getenv("mongodb_port") 15 | 16 | MONGODB_CONNECTION_STRING = f"mongodb://{MONGODB_USER}:{MONGODB_PASS}@{MONGODB_SERVER}:{MONGODB_PORT}" 17 | 18 | # Asynchronous MongoDB connection 19 | async_connection_string = f'{MONGODB_CONNECTION_STRING}' # This can be the same as the synchronous connection string 20 | async_mdb_client = AsyncIOMotorClient(async_connection_string) # setting mongodb client for asynchronous operations 21 | async_database = async_mdb_client['fastapi_db'] # database name in mongodb for asynchronous operations 22 | 23 | 24 | # Function to validate connection 25 | async def validate_mongodb_connection(): 26 | try: 27 | # Attempt to count documents in a specific collection, e.g., 'test_collection' 28 | count = await async_database['test_collection'].count_documents({}) 29 | print(f"Connection Successful! Found {count} documents in 'test_collection'.") 30 | except Exception as e: 31 | logger.info(f"Connection to MongoDB failed: {e}") 32 | 33 | # Running the validation function 34 | if __name__ == "__main__": 35 | loop = asyncio.get_event_loop() 36 | loop.run_until_complete(validate_mongodb_connection()) -------------------------------------------------------------------------------- /app/db/redisClient.py: -------------------------------------------------------------------------------- 1 | import redis.asyncio as aioredis 2 | 3 | from app.components.logger import logger 4 | 5 | 6 | class AsyncRedisClient: 7 | _instance = None 8 | 9 | @classmethod 10 | async def get_instance(cls): 11 | """ 12 | Asynchronously get the singleton instance of the Redis client. 13 | """ 14 | if cls._instance is None: 15 | cls._instance = await cls.create_redis_client() 16 | return cls._instance 17 | 18 | @staticmethod 19 | async def create_redis_client(): 20 | """ 21 | Asynchronously create a Redis client. Tries to connect to localhost, redis, and 0.0.0.0. 22 | """ 23 | hosts = ['localhost', 'redis', '0.0.0.0'] 24 | for host in hosts: 25 | try: 26 | client = aioredis.StrictRedis(host=host, port=6379, db=0, decode_responses=True) 27 | # The ping command is now an awaitable coroutine 28 | if await client.ping(): 29 | logger.info(f"Successfully connected to Redis server at {host}") 30 | return client 31 | except aioredis.ConnectionError as e: 32 | logger.error(f"Could not connect to Redis server at {host}: {e}.") 33 | raise Exception("Could not connect to any Redis server.") 34 | 35 | # # An Redis Lock Example for Future Implementations When Running Multiple Workers 36 | # async def create_initial_users(redis_client): 37 | # lock_key = "lock_users_creation" # Unique key for locking 38 | # expiry_seconds = 120 # Lock expiration time 39 | # 40 | # try: 41 | # lock_acquired = await redis_client.set(lock_key, "1", ex=expiry_seconds, nx=True) 42 | # if lock_acquired: 43 | # try: 44 | # await create_owner() 45 | # except Exception as e: 46 | # logger.error(f"Error during schedule contact update: {e}") 47 | # await asyncio.sleep(expiry_seconds) 48 | # else: 49 | # await asyncio.sleep(30) 50 | # finally: 51 | # await redis_client.close() 52 | # await redis_client.connection_pool.disconnect() 53 | -------------------------------------------------------------------------------- /app/routers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgekhananaev/PyNextStack/7546865fa1cd8b46140194813d9dae3e7310d925/app/routers/__init__.py -------------------------------------------------------------------------------- /app/routers/auth.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import Any 3 | 4 | from fastapi import APIRouter, HTTPException, Depends, status, Header, Request 5 | from fastapi.security import OAuth2PasswordBearer 6 | from passlib.context import CryptContext 7 | 8 | from app.classes.Auth import SimpleAuthForm 9 | from app.components.auth.jwt_token_handler import create_jwt_access_token 10 | from app.components.logger import logger 11 | from app.db.mongoClient import async_database 12 | 13 | router = APIRouter() 14 | 15 | user_collection = async_database.users # Get the collection from the database 16 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") # setup password hashing context 17 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") # Dependency 18 | 19 | 20 | async def authenticate_user(username: str, password: str): 21 | """ 22 | Authenticate the user by connecting to MongoDB asynchronously and checking the password. 23 | :param username: 24 | :param password: 25 | :return: 26 | """ 27 | user = await user_collection.find_one({"username": username}) # Use `await` for async operation 28 | if not user: 29 | return False 30 | if not pwd_context.verify(password, user.get("hashed_password")): # Verify the password 31 | return False 32 | return user 33 | 34 | 35 | @router.post("/token") 36 | async def login_for_access_token(request: Request, form_data: SimpleAuthForm = Depends()): 37 | """ 38 | Log-in and generate access token. 39 | 40 | :param request: The request object, providing access to HTTP request properties. 41 | :param form_data: The form data from the login request, containing the username and password. 42 | :return: A JSON object containing the access token and token type. 43 | 44 | This endpoint verifies the user's credentials. If valid, it generates a JWT access token that the user 45 | can use for authenticated requests. The token includes a 'sub' claim containing the username, and it 46 | has a default expiration time. This token is also stored in Redis with an expiration time for validation 47 | on subsequent requests. 48 | """ 49 | try: 50 | # Authenticate the user 51 | user: dict | Any = await authenticate_user(form_data.username, form_data.password) 52 | if not user: 53 | logger.warning(f"Login attempt failed for user: {form_data.username}") 54 | raise HTTPException( 55 | status_code=status.HTTP_401_UNAUTHORIZED, 56 | detail="Incorrect username or password", 57 | ) 58 | 59 | # Specify token expiration duration 60 | access_token_expires = timedelta(minutes=30) # Token validity can be adjusted 61 | 62 | # Create JWT access token 63 | access_token = await create_jwt_access_token( 64 | request=request, 65 | data={"sub": user["username"]}, # 'sub' claim to include the username 66 | expires_delta=access_token_expires 67 | ) 68 | 69 | logger.info(f"Login successful for user: {form_data.username}") 70 | return {"access_token": access_token, "token_type": "bearer"} 71 | 72 | except HTTPException: # Catch and re-raise HTTPException to ensure it stops execution 73 | raise 74 | 75 | except Exception as e: 76 | logger.error(f"An error occurred during login attempt: {str(e)}") 77 | raise HTTPException( 78 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 79 | detail="An error occurred during the login process." 80 | ) 81 | 82 | 83 | @router.post("/logout") 84 | async def logout(request: Request, api_key: str = Header(...)): 85 | """ 86 | Invalidate the user's current JWT token to log them out. 87 | """ 88 | try: 89 | token_key = f"API_KEY_{api_key}" 90 | # Use the async get_instance method to get the Redis client 91 | redis_client = request.app.state.redis 92 | 93 | # Async call to check if the token exists 94 | token_exists = await redis_client.exists(token_key) 95 | if not token_exists: 96 | raise HTTPException(status_code=404, detail="Token not found or already invalidated") 97 | 98 | # Invalidate the token by deleting it from Redis asynchronously 99 | await redis_client.delete(token_key) 100 | return {"message": "Logged out successfully."} 101 | except Exception as e: 102 | raise HTTPException( 103 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 104 | detail=f"An error occurred: {str(e)}" 105 | ) 106 | -------------------------------------------------------------------------------- /app/routers/chatgpt.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | from starlette.responses import JSONResponse 3 | 4 | from app.classes.Chatgpt import ChatGptModelEnum 5 | from app.components.auth.check_permissions import check_permissions 6 | from app.components.chat_gpt.chatgpt_service import ask_chatgpt_with_context 7 | 8 | router = APIRouter() # loading the FastAPI app 9 | 10 | 11 | @router.get("/chat/", dependencies=[Depends(check_permissions)]) 12 | async def chat_with_gpt(question: str, model: ChatGptModelEnum): 13 | """ 14 | Chat with ChatGPT 15 | """ 16 | answer = ask_chatgpt_with_context(question, model) 17 | return JSONResponse(content={"message": answer}) 18 | -------------------------------------------------------------------------------- /app/routers/register.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from datetime import timedelta 3 | 4 | from bson import ObjectId 5 | from fastapi import status, Request, HTTPException, APIRouter 6 | 7 | from app.classes.User import User, Role, UserRegistration 8 | from app.components.hash_password import hash_password 9 | from app.components.logger import logger 10 | from app.components.message_dispatcher.mail import send_email_and_save 11 | from app.db.mongoClient import async_database 12 | from app.routers.users import user_exists 13 | 14 | router = APIRouter() # router instance 15 | user_collection = async_database.users # Get the collection from the database 16 | 17 | 18 | @router.post("/register/", response_model=User) 19 | async def create_user(user_registration: UserRegistration): 20 | """ 21 | User registration endpoint. 22 | This function handles the registration of a new user. 23 | It checks for uniqueness of the email and username, hashes the password, and inserts a new user into the database. 24 | """ 25 | 26 | try: 27 | # Check if the user with the given email or username already exists. 28 | if await user_exists(email=user_registration.email, username=user_registration.username): 29 | detail_msg = "The email or username is already in use." 30 | logger.warning( 31 | f"Attempt to create a user with an existing email or username by {user_registration.username}") 32 | # If the user exists, raise an HTTP exception with a 400 status code. 33 | raise HTTPException( 34 | status_code=status.HTTP_400_BAD_REQUEST, 35 | detail=detail_msg 36 | ) 37 | 38 | # Hash the plaintext password provided in the registration. 39 | hashed_password = hash_password(user_registration.password) 40 | 41 | # Prepare the user document for insertion into the database. 42 | # This includes removing the plaintext password and adding hashed_password, role, and disabled status. 43 | user_dict = user_registration.dict() 44 | user_dict["hashed_password"] = hashed_password 45 | del user_dict["password"] # Remove plaintext password from the document 46 | user_dict["role"] = Role.user.value # Explicitly set the user's role to 'user' 47 | user_dict["disabled"] = False # New users are not disabled by default 48 | 49 | # Insert the new user document into the database collection. 50 | new_user = await user_collection.insert_one(user_dict) 51 | 52 | # Retrieve the newly created user document from the database to return it. 53 | created_user = await user_collection.find_one({"_id": new_user.inserted_id}) 54 | created_user['_id'] = str(created_user['_id']) # Convert ObjectId to string for JSON serialization 55 | del created_user["hashed_password"] # Remove hashed password from the returned user document 56 | 57 | logger.info(f"Registered new user {user_registration.username} successfully.") 58 | 59 | # Return the created user, converting the MongoDB document to the User model. 60 | return User.from_mongo(created_user) 61 | except Exception as e: 62 | logger.error(f"Error registering new user {user_registration.username}: {str(e)}") 63 | # Raise a 500 status code HTTP exception if an unexpected error occurs during registration. 64 | raise HTTPException(status_code=500, detail="An error occurred while creating the user.") 65 | 66 | 67 | @router.post("/users/forgot-password/") 68 | async def forgot_password(email: str, request: Request): 69 | """ 70 | Initiates the password reset process for a user identified by their email. 71 | 72 | This endpoint will: 73 | - Verify if a user with the provided email exists in the database. 74 | - Generate a secure, random token to be used as a password reset token. 75 | - Store this token in Redis with an expiration time, linking it to the user's ID. 76 | - Email the user with a link containing the password reset token. 77 | 78 | Args: 79 | - email (str): The email address of the user requesting a password reset. 80 | - request (Request): The request object, used to access app state like the Redis client. 81 | 82 | Returns: 83 | - A message indicating that a password reset link has been sent if a user with the provided email exists. 84 | """ 85 | 86 | # Look for the user in the database using the provided email address. 87 | user = await user_collection.find_one({"email": email}) 88 | redis_client = request.app.state.redis 89 | 90 | # If no user is found with the provided email, return a 404 error. 91 | if not user: 92 | raise HTTPException(status_code=404, detail="User with this email does not exist.") 93 | 94 | # Generate a secure, random token for the password reset 95 | reset_token = secrets.token_urlsafe(64) 96 | # reset_token_expires = int((datetime.utcnow() + timedelta(hours=1)).timestamp()) 97 | 98 | # Use Redis to store the token with an expiration time 99 | await redis_client.setex(f"reset_token:{reset_token}", timedelta(hours=1), value=str(user["_id"])) 100 | 101 | # Email the user with the reset token 102 | reset_link = f"http://localhost:3000/reset-password/{reset_token}" 103 | await send_email_and_save( 104 | subject="Password Reset Request", 105 | body=f"Please click on the link to reset your password: {reset_link}, the URL is valid for one hour.", 106 | to_emails=[email], 107 | files=[] 108 | ) 109 | return {"message": "If an account with this email was found, a password reset link has been sent."} 110 | 111 | 112 | @router.post("/users/reset-password/") 113 | async def reset_password(token: str, new_password: str, request: Request): 114 | """Reset user password given a valid token and new password. 115 | 116 | This endpoint retrieves the user ID associated with the provided token from Redis, 117 | verifies that the token is valid (i.e., has not expired and exists), hashes the new password, 118 | updates the user's password in the database, and then optionally deletes the token from Redis. 119 | """ 120 | 121 | # Access the Redis client from the application state 122 | redis_client = request.app.state.redis 123 | 124 | # Attempt to retrieve the user_id associated with the provided token 125 | user_id_str = await redis_client.get(f"reset_token:{token}") 126 | if not user_id_str: 127 | # If no user_id is found, the token is invalid or expired 128 | raise HTTPException(status_code=400, detail="Invalid or expired password reset token.") 129 | 130 | # Convert the user_id string to ObjectId for MongoDB. 131 | # No need to decode since it's already a string. 132 | user_id = ObjectId(user_id_str) 133 | 134 | # Hash the new password before storing it 135 | hashed_password = hash_password(new_password) 136 | 137 | # Update the user's password in the database 138 | result = await user_collection.update_one( 139 | {"_id": user_id}, 140 | {"$set": {"hashed_password": hashed_password}} 141 | ) 142 | 143 | # Check if the password was actually updated 144 | if result.modified_count == 0: 145 | # This means the user ID did not match any document in the database 146 | raise HTTPException(status_code=500, detail="Failed to reset the password.") 147 | 148 | # Optionally, delete the token from Redis after successful password reset 149 | await redis_client.delete(f"reset_token:{token}") 150 | 151 | # Return a success message 152 | return {"message": "Password has been reset successfully."} -------------------------------------------------------------------------------- /app/routers/senders.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from fastapi import APIRouter, UploadFile, File, Form 3 | from app.components.message_dispatcher.mail import send_email_and_save 4 | 5 | router = APIRouter() 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/routers/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgekhananaev/PyNextStack/7546865fa1cd8b46140194813d9dae3e7310d925/app/routers/settings/__init__.py -------------------------------------------------------------------------------- /app/routers/settings/messages.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from bson import ObjectId 4 | from fastapi import APIRouter, UploadFile, File, Form, HTTPException 5 | 6 | from app.classes.Messages import MessagesConfigModel 7 | from app.components.message_dispatcher.mail import send_email_and_save, test_email_connection 8 | from app.db.mongoClient import async_mdb_client, async_database 9 | 10 | router = APIRouter() 11 | 12 | # mongo connection 13 | config_collection = async_database.settings 14 | emails_collection = async_mdb_client.messages.emails_sent 15 | 16 | 17 | @router.get("/config", response_model=MessagesConfigModel) 18 | async def get_config(): 19 | """Fetch a specific configuration object from the database. 20 | 21 | Raises a 404 error if the configuration cannot be found. 22 | """ 23 | 24 | config_id = "65fdaaca4f94194ff730d3be" # noqa 25 | config = await config_collection.find_one({"_id": ObjectId(config_id)}) # Use ObjectId to convert the config_id 26 | if config: 27 | return config 28 | else: 29 | raise HTTPException(status_code=404, detail="Configuration not found") 30 | 31 | 32 | @router.put("/config", response_model=MessagesConfigModel) 33 | async def update_config(config: MessagesConfigModel): 34 | """Update an existing configuration object in the database. 35 | 36 | If the specified configuration does not exist, a 404 error is raised. 37 | This method ensures that the MongoDB document structure is respected during the update. 38 | """ 39 | config_id = "65fdaaca4f94194ff730d3be" # noqa 40 | # Use ObjectId to convert the config_id 41 | existing_config = await config_collection.find_one({"_id": ObjectId(config_id)}) 42 | if existing_config is None: 43 | raise HTTPException(status_code=404, detail="Configuration not found") 44 | # Need to ensure that the conversion to dictionary (if necessary) and merging with updates respects MongoDB's document structure and idiosyncrasies 45 | updated_config = {**existing_config, **config.dict(exclude_unset=True)} 46 | # When replacing, you also need to make sure the _id is not altered. Since ObjectId is not JSON serializable, exclude it from the update. 47 | updated_config.pop('_id', None) # Remove _id if present, to avoid issues with MongoDB's _id immutability 48 | await config_collection.replace_one({"_id": ObjectId(config_id)}, updated_config) 49 | return updated_config 50 | 51 | 52 | @router.post("/test-email/") 53 | async def test_email_route(): 54 | """Tests the email system connectivity and configuration. 55 | 56 | If the test fails, a 500 internal server error is returned with the error details. 57 | """ 58 | try: 59 | result = await test_email_connection() 60 | return result 61 | except Exception as e: 62 | raise HTTPException(status_code=500, detail=str(e)) 63 | 64 | 65 | 66 | @router.post("/send-email/") 67 | async def send_email( 68 | subject: str = Form(...), 69 | body: str = Form(...), 70 | to_emails: List[str] = Form(...), 71 | files: List[UploadFile] = File([]) 72 | ): 73 | """Sends an email and saves its details to the database. 74 | 75 | The email is sent to the specified recipient(s) with optional file attachments. 76 | """ 77 | return await send_email_and_save(subject, body, to_emails, files) 78 | 79 | -------------------------------------------------------------------------------- /app/routers/users.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from bson import ObjectId 4 | from fastapi import APIRouter, HTTPException, Depends, Query 5 | from starlette import status 6 | 7 | from app.classes.User import UserCreate, User, UpdateUser 8 | from app.components.auth.check_permissions import check_permissions 9 | from app.components.auth.jwt_token_handler import get_jwt_username 10 | from app.components.hash_password import hash_password 11 | from app.components.logger import logger 12 | from app.db.mongoClient import async_database 13 | 14 | router = APIRouter() # router instance 15 | user_collection = async_database.users # Get the collection from the database 16 | 17 | 18 | async def user_exists(email: str = None, username: str = None) -> bool: 19 | query = {} 20 | if email: 21 | query["email"] = email 22 | if username: 23 | query["username"] = username 24 | if query: 25 | return await user_collection.find_one({"$or": [query]}) is not None 26 | return False 27 | 28 | @router.get("/users/", response_model=List[User], dependencies=[Depends(check_permissions)]) 29 | async def get_users(username: str = Depends(get_jwt_username)): 30 | try: 31 | users = [] 32 | async for user in user_collection.find({}): 33 | users.append(User.from_mongo(user)) 34 | logger.info(f"User list requested by {username} - Success") 35 | return users 36 | except Exception as e: 37 | logger.error(f"User list requested by {username} - Failed: {str(e)}") 38 | raise HTTPException( 39 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 40 | detail="An error occurred while fetching users." 41 | ) 42 | 43 | @router.get("/users/profile", response_model=User, dependencies=[Depends(check_permissions)]) 44 | async def get_profile(current_username: str = Depends(get_jwt_username)): 45 | try: 46 | # Find the current user based on the username obtained from the JWT token 47 | user = await user_collection.find_one({"username": current_username}) 48 | if not user: 49 | logger.warning(f"Profile not found for {current_username}") 50 | raise HTTPException(status_code=404, detail="Profile not found") 51 | user["_id"] = str(user["_id"]) 52 | logger.info(f"Profile fetched for user {current_username} successfully.") 53 | return user 54 | except Exception as e: 55 | logger.error(f"Error fetching profile for {current_username}: {str(e)}") 56 | raise HTTPException(status_code=500, detail="An error occurred while fetching the profile.") 57 | 58 | 59 | @router.get("/users/{id}", response_model=User, dependencies=[Depends(check_permissions)]) 60 | async def get_user(id: str, username: str = Depends(get_jwt_username)): # noqa 61 | try: 62 | user = await user_collection.find_one({"_id": ObjectId(id)}) 63 | if not user: 64 | logger.warning(f"User with ID {id} not found by {username}") 65 | raise HTTPException(status_code=404, detail="User with ID {id} not found") 66 | user["_id"] = str(user["_id"]) 67 | logger.info(f"User {username} fetched user {id} successfully.") 68 | return user 69 | except Exception as e: 70 | logger.error(f"Error fetching user by {username}: {str(e)}") 71 | raise HTTPException(status_code=500, detail="An error occurred while fetching the user.") 72 | 73 | 74 | @router.get("/check_user_exists", response_model=bool) 75 | async def check_user_exists(email: str = Query(None), username: str = Query(None)): 76 | # Validate input 77 | if not email and not username: 78 | raise HTTPException( 79 | status_code=status.HTTP_400_BAD_REQUEST, 80 | detail="Either an email or a username must be provided." 81 | ) 82 | 83 | # Use the utility function to check if the user exists 84 | exists = await user_exists(email=email, username=username) 85 | 86 | return exists 87 | 88 | 89 | @router.post("/users/", response_model=User, dependencies=[Depends(check_permissions)]) 90 | async def create_user(user: UserCreate, username: str = Depends(get_jwt_username)): 91 | try: 92 | # Check if the email or username already exists 93 | if await user_exists(email=user.email, username=user.username): 94 | detail_msg = "The email or username is already in use." 95 | logger.warning(f"Attempt to create a user with an existing email or username by {username}") 96 | raise HTTPException( 97 | status_code=status.HTTP_400_BAD_REQUEST, 98 | detail=detail_msg 99 | ) 100 | 101 | # Hash the user's password for storage 102 | hashed_password = hash_password(user.password) 103 | user_dict = user.dict() 104 | user_dict["hashed_password"] = hashed_password 105 | del user_dict["password"] 106 | 107 | # Insert the new user into the database 108 | new_user = await user_collection.insert_one(user_dict) 109 | created_user = await user_collection.find_one({"_id": new_user.inserted_id}) 110 | created_user['_id'] = str(created_user['_id']) 111 | del created_user["hashed_password"] 112 | 113 | logger.info(f"User {username} created new user {user.username} successfully.") 114 | return created_user 115 | except Exception as e: 116 | logger.error(f"Error creating user by {username}: {str(e)}") 117 | raise HTTPException(status_code=500, detail="An error occurred while creating the user.") 118 | 119 | @router.put("/users/{id}", dependencies=[Depends(check_permissions)]) 120 | async def update_user(id: str, update_data: UpdateUser, username: str = Depends(get_jwt_username)): # noqa 121 | try: 122 | update_data_dict = update_data.dict(exclude_unset=True) 123 | if "password" in update_data_dict: 124 | update_data_dict["hashed_password"] = hash_password(update_data_dict.pop("password")) 125 | 126 | updated_user = await user_collection.find_one_and_update( 127 | {"_id": ObjectId(id)}, 128 | {"$set": update_data_dict}, 129 | return_document=True 130 | ) 131 | if not updated_user: 132 | logger.warning(f"User with ID {id} not found by {username}") 133 | raise HTTPException(status_code=404, detail="User with ID {id} not found") 134 | 135 | updated_user['_id'] = str(updated_user['_id']) 136 | del updated_user["hashed_password"] 137 | 138 | logger.info(f"User {username} updated user {id} successfully.") 139 | return updated_user 140 | except Exception as e: 141 | logger.error(f"Error updating user by {username}: {str(e)}") 142 | raise HTTPException(status_code=500, detail="An error occurred while updating the user.") 143 | 144 | 145 | @router.delete("/users/{id}", dependencies=[Depends(check_permissions)]) 146 | async def delete_user(id: str, username: str = Depends(get_jwt_username)): # noqa 147 | """ 148 | Delete user by ID, the site owner cannot be deleted. 149 | """ 150 | try: 151 | # Retrieve user data from MongoDB based on the provided ID 152 | user_data = await user_collection.find_one({"_id": ObjectId(id)}) 153 | 154 | # Check if user data is found 155 | if not user_data: 156 | logger.warning(f"Attempt to delete non-existing user with ID {id} by {username}") 157 | raise HTTPException(status_code=404, detail=f"User with ID {id} not found") 158 | 159 | # Assuming user data includes a field for the user's role, such as 'role' 160 | user_role = user_data.get('role', None) 161 | 162 | # Check if the user is an owner (or has the permission to delete) 163 | if user_role == "owner": 164 | logger.warning(f"Attempt to delete user with ID {id} who is an owner by {username}") 165 | raise HTTPException(status_code=403, detail="Owners are not allowed to be deleted.") 166 | 167 | # Proceed with user deletion if the user is not an owner 168 | delete_result = await user_collection.delete_one({"_id": ObjectId(id)}) 169 | 170 | if delete_result.deleted_count == 0: 171 | logger.warning(f"Attempt to delete non-existing user with ID {id} by {username}") 172 | raise HTTPException(status_code=404, detail=f"User with ID {id} not found") 173 | 174 | logger.info(f"User {username} deleted user {id} successfully.") 175 | return {"message": "User deleted successfully."} 176 | 177 | except HTTPException: 178 | raise # Re-raise HTTPException to let FastAPI handle it 179 | 180 | except Exception as e: 181 | logger.error(f"Error deleting user by {username}: {str(e)}") 182 | raise HTTPException(status_code=500, detail="An error occurred while deleting the user.") 183 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.12-slim' 2 | 3 | services: 4 | fastapi: 5 | build: 6 | context: . 7 | ports: 8 | - "8000:8000" 9 | environment: 10 | - REDIS_URL=redis://redis:6379/0 11 | - NEXT_PUBLIC_API_KEY=${static_bearer_secret_key} 12 | depends_on: 13 | - mongodb 14 | - redis 15 | 16 | nextjs: 17 | build: 18 | context: ./nextjs 19 | dockerfile: Dockerfile 20 | volumes: 21 | - ./nextjs:/app 22 | - /app/node_modules 23 | ports: 24 | - "3000:3000" 25 | 26 | mongodb: 27 | image: mongo:latest 28 | environment: 29 | - MONGO_INITDB_ROOT_USERNAME=bringthemhome 30 | - MONGO_INITDB_ROOT_PASSWORD=${mongodb_password} 31 | ports: 32 | - "27017:27017" # for testing purposes you can expose the MongoDB port 33 | volumes: 34 | - mongodb_data:/data/db 35 | 36 | redis: 37 | image: redis:latest 38 | volumes: 39 | - redis_data:/data 40 | 41 | volumes: 42 | mongodb_data: 43 | redis_data: 44 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # script.sh 4 | 5 | # Replace 'mongodb_server=localhost' with 'mongodb_server=mongodb' in .env 6 | sed -i 's/mongodb_server=localhost/mongodb_server=mongodb/' /app/.env 7 | 8 | # Execute the main container command. 9 | exec "$@" 10 | -------------------------------------------------------------------------------- /generate_env.py: -------------------------------------------------------------------------------- 1 | import os 2 | import secrets 3 | import string 4 | 5 | 6 | def generate_secret_key(length=32): 7 | # Define the character pool including numbers, uppercase, and lowercase letters 8 | characters = string.ascii_letters + string.digits # This includes [a-zA-Z0-9] 9 | # Generate a random secret key from the pool 10 | key = ''.join(secrets.choice(characters) for _ in range(length)) 11 | return key 12 | 13 | 14 | def load_chatgpt_credentials(file_path='chatgpt_credentials.env'): 15 | # Initialize a dictionary to hold the credentials 16 | credentials = {} 17 | try: 18 | # Open the credentials file and read the contents 19 | with open(file_path, 'r') as file: 20 | for line in file: 21 | # Split each line by the equals sign into key and value 22 | key, value = line.strip().split('=', 1) 23 | credentials[key] = value 24 | except FileNotFoundError: 25 | print(f"Warning: {file_path} not found. Skipping ChatGPT credentials.") 26 | return credentials 27 | 28 | 29 | def generate_nextjs_env_file(static_bearer_secret_key): # noqa 30 | nextjs_env_content = f""" 31 | NEXT_PUBLIC_API_BASE_URL=http://localhost:8000/api/v1 32 | NEXT_PUBLIC_API_KEY={static_bearer_secret_key} 33 | """ 34 | nextjs_folder_path = "./nextjs" 35 | if not os.path.exists(nextjs_folder_path): 36 | os.makedirs(nextjs_folder_path) 37 | 38 | with open(os.path.join(nextjs_folder_path, '.env'), 'w') as file: 39 | file.write(nextjs_env_content) 40 | 41 | 42 | def main(): 43 | mongodb_fastapi_password = generate_secret_key(length=16) # New password for MongoDB and FastAPI UI 44 | chatgpt_credentials = load_chatgpt_credentials() 45 | static_bearer_secret_key = generate_secret_key(length=32) 46 | 47 | # Prepare the settings with the generated static_bearer_secret_key 48 | env_content = f""" 49 | # You can set your backend settings here. 50 | 51 | # mongodb connection 52 | mongodb_server=mongodb 53 | mongodb_port=27017 54 | mongodb_username=bringthemhome 55 | mongodb_password={mongodb_fastapi_password} 56 | 57 | # fastapi 58 | fastapi_ui_username=bringthemhome 59 | fastapi_ui_password=bringthemhome 60 | 61 | # static_bearer_secret_key to protect your register from unauthorized access 62 | static_bearer_secret_key={static_bearer_secret_key} 63 | jwt_secret_key=bringthemhome 64 | algorithm=HS256 65 | 66 | # chatgpt 67 | open_ai_organization={chatgpt_credentials.get('open_ai_organization', 'this')} 68 | open_ai_secret_key={chatgpt_credentials.get('open_ai_secret_key', 'this')} 69 | 70 | # default admin user 71 | owner_username=root 72 | owner_password=bringthemhome 73 | owner_email=israel@israeli.com 74 | """.strip() 75 | 76 | # Write the combined content to an .env file 77 | with open('.env', 'w') as file: 78 | file.write(env_content) 79 | 80 | generate_nextjs_env_file(static_bearer_secret_key) 81 | 82 | print("Generated .env with keys and additional settings") 83 | print("Generated .env for Next.js") 84 | 85 | 86 | if __name__ == "__main__": 87 | main() 88 | -------------------------------------------------------------------------------- /nextjs/.dockerignore: -------------------------------------------------------------------------------- 1 | ### NextJS template 2 | # dependencies 3 | /node_modules 4 | /.pnp 5 | .pnp.js 6 | 7 | # testing 8 | /coverage 9 | 10 | # next.js 11 | /.next/ 12 | /out/ 13 | 14 | # production 15 | /build 16 | 17 | # misc 18 | .DS_Store 19 | *.pem 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | .pnpm-debug.log* 26 | 27 | # vercel 28 | .vercel 29 | 30 | # typescript 31 | *.tsbuildinfo 32 | next-env.d.ts 33 | 34 | -------------------------------------------------------------------------------- /nextjs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /nextjs/Dockerfile: -------------------------------------------------------------------------------- 1 | # Assuming you're using Node.js 2 | FROM node:21.7.1-slim 3 | 4 | # Set the working directory in the container 5 | WORKDIR /nextjs 6 | 7 | ## Set environment variables 8 | #ENV API_BASE_URL="http://localhost:8000/api/v1" 9 | #ENV NEXT_PUBLIC_API_KEY="bringthemhome" 10 | 11 | # Copy the current directory contents into the container at /app 12 | COPY . . 13 | 14 | # Install any dependencies 15 | RUN npm install 16 | 17 | # If you're building a static site or need to run a build script 18 | RUN npm run build 19 | 20 | # Expose the port your app runs on 21 | EXPOSE 3000 22 | 23 | # Define the command to run your app 24 | CMD ["npm", "start"] 25 | -------------------------------------------------------------------------------- /nextjs/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /nextjs/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "py-next-stack", 3 | "version": "v31.03.2024", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@emotion/react": "^11.11.4", 13 | "@emotion/styled": "^11.11.0", 14 | "@mui/icons-material": "^5.15.13", 15 | "@mui/material": "^5.15.13", 16 | "axios": "^1.6.8", 17 | "js-cookie": "^3.0.5", 18 | "next": "14.1.3", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "react-hot-toast": "^2.4.1", 22 | "react-query": "^3.39.3", 23 | "react-tsparticles": "^2.12.2", 24 | "recharts": "^2.12.3", 25 | "tsparticles": "^3.3.0", 26 | "tsparticles-slim": "^2.12.0" 27 | }, 28 | "devDependencies": { 29 | "@types/node": "20.11.28", 30 | "typescript": "5.4.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /nextjs/src/api/auth/auth-context.js: -------------------------------------------------------------------------------- 1 | import React, {createContext, useContext, useEffect, useState} from 'react'; 2 | import Cookies from 'js-cookie'; 3 | import {getProfile, logout as apiLogout} from "@/api/endpoints"; 4 | 5 | // Create a context for auth-related data and operations. 6 | const AuthContext = createContext(); 7 | 8 | // Custom hook to use the auth context in other components. 9 | export const useAuth = () => useContext(AuthContext); 10 | 11 | // AuthProvider component that wraps the application and provides auth state. 12 | export const AuthProvider = ({children}) => { 13 | // State for the access token. 14 | const [accessToken, setAccessToken] = useState(null); 15 | // State for the user profile object. 16 | const [userProfile, setUserProfile] = useState(null); 17 | // State to track if the user is authenticated. 18 | const [isAuthenticated, setIsAuthenticated] = useState(false); 19 | // State to track if the auth-related data is still loading. 20 | const [isLoading, setIsLoading] = useState(true); 21 | 22 | // Effect that runs once on component mount. 23 | useEffect(() => { 24 | // Function to check for an existing session and set auth state. 25 | const checkForExistingSession = async () => { 26 | // Attempt to retrieve the access token from cookies. 27 | const storedToken = Cookies.get('accessToken'); 28 | if (storedToken) { 29 | // If a token is found, attempt to fetch the user profile. 30 | setAccessToken(storedToken); 31 | try { 32 | const profile = await getProfile(storedToken); 33 | if (profile) { 34 | // If a profile is successfully fetched, update auth state. 35 | setUserProfile(profile); 36 | setIsAuthenticated(true); 37 | } else { 38 | // If no profile is returned, consider the user unauthenticated. 39 | setIsAuthenticated(false); 40 | Cookies.remove('accessToken'); 41 | } 42 | } catch (error) { 43 | // Handle any errors during profile fetch. 44 | console.error("Error fetching user profile:", error); 45 | setIsAuthenticated(false); 46 | Cookies.remove('accessToken'); 47 | } 48 | } else { 49 | // If no token is found, set the user as unauthenticated. 50 | setIsAuthenticated(false); 51 | } 52 | // Indicate that loading of auth data is complete. 53 | setIsLoading(false); 54 | }; 55 | 56 | // Invoke the session check function. 57 | checkForExistingSession(); 58 | }, []); 59 | 60 | // Function to handle user logout. 61 | const logout = async () => { 62 | if (accessToken) { 63 | try { 64 | // Attempt to call the API's logout endpoint. 65 | await apiLogout(accessToken); 66 | } catch (error) { 67 | // Handle any errors during logout. 68 | console.error('Error during logout:', error); 69 | } 70 | } 71 | // Clear auth state and remove the token cookie. 72 | setIsAuthenticated(false); 73 | setAccessToken(null); 74 | setUserProfile(null); 75 | Cookies.remove('accessToken'); 76 | setIsLoading(false); 77 | }; 78 | 79 | // Function to handle setting the access token. 80 | const value = { 81 | accessToken, 82 | setAccessToken: (token) => { 83 | // Store the token in cookies. 84 | Cookies.set('accessToken', token, {expires: 1, secure: true, sameSite: 'strict'}); 85 | // Update the access token state. 86 | setAccessToken(token); 87 | setIsAuthenticated(true); // Assume authentication is successful. 88 | // Attempt to fetch the user profile with the new token. 89 | getProfile(token).then(profile => { 90 | setUserProfile(profile); 91 | setIsAuthenticated(true); // Confirm authentication. 92 | }).catch(error => { 93 | // Handle any errors during profile fetch. 94 | console.error("Error setting access token and fetching profile:", error); 95 | setIsAuthenticated(false); 96 | setUserProfile(null); // Clear user profile on error. 97 | }); 98 | }, 99 | isAuthenticated, 100 | setIsAuthenticated, 101 | userProfile, 102 | isLoading, 103 | logout, 104 | }; 105 | 106 | // Provide the auth context to child components. 107 | return {children}; 108 | }; 109 | -------------------------------------------------------------------------------- /nextjs/src/api/endpoints.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import {jsonHeader, staticBearerHeader} from "@/api/headers"; 3 | 4 | const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL; 5 | 6 | export const apiClient = axios.create({ 7 | baseURL: API_BASE_URL, 8 | }); 9 | 10 | // Function to log-in and get token 11 | export const getToken = async (username, password) => { 12 | const response = await axios.post(`${API_BASE_URL}/token?username=${username}&password=${password}`, null, { 13 | headers: jsonHeader 14 | }); 15 | return response.data; 16 | }; 17 | 18 | // Function to get user profile 19 | export const getProfile = async (accessToken) => { 20 | const response = await axios.get(`${API_BASE_URL}/users/profile`, { 21 | headers: jsonHeader(accessToken), 22 | }); 23 | return response.data; 24 | }; 25 | 26 | // log out function 27 | export const logout = async (accessToken) => { 28 | await axios.post(`${API_BASE_URL}/logout`, {}, {headers: jsonHeader(accessToken)}); 29 | 30 | }; 31 | 32 | 33 | // Users related functions, get, post, delete, put 34 | export const fetchUsers = async (accessToken) => { 35 | const {data} = await axios.get(`${API_BASE_URL}/users/`, { 36 | headers: jsonHeader(accessToken) 37 | }); 38 | return data; 39 | }; 40 | 41 | export const updateUser = async (user, accessToken) => { 42 | // noinspection JSUnresolvedReference 43 | const {data} = await axios.put(`${API_BASE_URL}/users/${user._id}`, user, { 44 | headers: jsonHeader(accessToken) 45 | }); 46 | return data; 47 | }; 48 | 49 | export const deleteUser = async (id, accessToken) => { 50 | await axios.delete(`${API_BASE_URL}/users/${id}`, { 51 | headers: jsonHeader(accessToken) 52 | }); 53 | return id; 54 | }; 55 | 56 | export const createUser = async (user, accessToken) => { 57 | console.log(user) 58 | const {data} = await axios.post(`${API_BASE_URL}/users/`, user, { 59 | headers: jsonHeader(accessToken) 60 | }); 61 | return data; 62 | }; 63 | 64 | 65 | // ChatGPT 66 | export const fetchChatResponse = async (question, accessToken, model) => { 67 | // Simulated API call 68 | console.log("Sending question to the API:", question, "with model", model); 69 | 70 | const url = new URL(`${API_BASE_URL}/chat/`); 71 | url.searchParams.append('question', question); 72 | url.searchParams.append('model', model); 73 | 74 | const response = await fetch(url, { 75 | method: 'GET', 76 | headers: { 77 | 'accept': 'application/json', 78 | 'api-key': accessToken, 79 | }, 80 | }); 81 | 82 | if (!response.ok) { 83 | throw new Error('Network response was not ok'); 84 | } 85 | return response.json(); 86 | }; 87 | 88 | 89 | export const forgotPassword = async (email) => { 90 | await axios.post(`${API_BASE_URL}/users/forgot-password/`, {}, { 91 | params: {email}, 92 | headers: staticBearerHeader, 93 | }); 94 | }; 95 | 96 | 97 | export const postResetPassword = async ({token, newPassword}) => { 98 | const response = await axios.post(`${API_BASE_URL}/users/reset-password/`, {}, { 99 | params: {token, new_password: newPassword}, 100 | headers: staticBearerHeader, 101 | }); 102 | return response.data; 103 | }; 104 | 105 | 106 | export const postRegister = async (userData) => { 107 | const response = await axios.post(`${API_BASE_URL}/register/`, { 108 | email: userData.email, 109 | full_name: userData.fullName, 110 | password: userData.password, 111 | username: userData.username, 112 | }, { 113 | headers: staticBearerHeader, 114 | }); 115 | 116 | return response.data; 117 | }; 118 | 119 | // mail settings 120 | export const fetchMessageSettings = async (accessToken) => { 121 | // Pass the accessToken directly to use it in the request 122 | const {data} = await apiClient.get('config', { 123 | headers: jsonHeader(accessToken) 124 | }); 125 | return data; 126 | }; 127 | 128 | export const updateMessageSettings = async ({section, settings, accessToken}) => { 129 | // Include accessToken in the request 130 | await apiClient.put(`config`, {[section]: settings}, { 131 | headers: jsonHeader(accessToken) 132 | }); 133 | return settings; 134 | }; -------------------------------------------------------------------------------- /nextjs/src/api/headers.js: -------------------------------------------------------------------------------- 1 | export const jsonHeader = (token) => ({ 2 | 'Accept': 'application/json', 3 | ...(token ? {'api-key': token} : {}), 4 | }); 5 | 6 | export const staticBearerHeader = { 7 | 'accept': 'application/json', 8 | 'Authorization': `Bearer ${process.env.NEXT_PUBLIC_API_KEY}`, 9 | }; -------------------------------------------------------------------------------- /nextjs/src/components/confirmation-modal.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import Modal from '@mui/material/Modal'; 3 | import Button from '@mui/material/Button'; 4 | import Box from '@mui/material/Box'; 5 | import Typography from '@mui/material/Typography'; 6 | 7 | const keyframes = ` 8 | @keyframes gentleZoomIn { 9 | from { transform: translate(-50%, -50%) scale(0.5); } 10 | to { transform: translate(-50%, -50%) scale(1); } 11 | } 12 | 13 | @keyframes gentleZoomOut { 14 | from { transform: translate(-50%, -50%) scale(1); } 15 | to { transform: translate(-50%, -50%) scale(0.5); opacity: 0; } 16 | } 17 | `; 18 | 19 | const modalStyle = (animation) => ({ 20 | position: 'absolute', 21 | top: '30%', 22 | left: '50%', 23 | transform: 'translate(-50%, -50%)', 24 | width: 340, 25 | bgcolor: 'background.paper', 26 | borderRadius: '15px', 27 | boxShadow: 24, 28 | p: 4, 29 | transformOrigin: 'center', 30 | animation: animation, 31 | }); 32 | 33 | const ConfirmModal = ({ 34 | onConfirm, 35 | confirmButtonName, 36 | buttonName, 37 | buttonSize = 'auto', 38 | buttonVariant = 'contained', 39 | confirmationText, 40 | title, 41 | margin, 42 | color, 43 | disabledButton = false, 44 | icon = null, // New prop for the icon 45 | iconPosition = 'start' // New prop for the icon position 46 | }) => { 47 | const [isOpen, setIsOpen] = useState(false); 48 | const [animation, setAnimation] = useState(''); 49 | 50 | const handleClose = () => { 51 | // Start the zoom-out animation 52 | setAnimation('gentleZoomOut 0.3s ease-out forwards'); 53 | // Close the modal after the animation finishes 54 | setTimeout(() => { 55 | setIsOpen(false); 56 | // Reset animation to none 57 | setAnimation(''); 58 | }, 500); // Duration of zoom-out animation 59 | }; 60 | 61 | const handleOpen = () => { 62 | setIsOpen(true); 63 | // Apply the zoom-in effect when opening 64 | setAnimation('gentleZoomIn 0.3s ease-out forwards'); 65 | }; 66 | 67 | const buttonProps = { 68 | ...(iconPosition === 'start' && {startIcon: icon}), 69 | ...(iconPosition === 'end' && {endIcon: icon}), 70 | }; 71 | 72 | return ( 73 | 74 | 75 | 84 | 85 | 87 | 88 | 91 | {title} 92 | 93 | 96 | {confirmationText} 97 | 98 | 101 | 109 | 117 | 118 | 119 | 120 | 121 | ); 122 | }; 123 | 124 | export default ConfirmModal; 125 | -------------------------------------------------------------------------------- /nextjs/src/components/error.js: -------------------------------------------------------------------------------- 1 | // components/ErrorDisplay.js 2 | import React from 'react'; 3 | import {Alert, AlertTitle, Box, Container, Typography} from '@mui/material'; 4 | 5 | const ErrorDisplay = ({message}) => { 6 | return ( 7 | 8 | 9 | {/**/} 10 | 11 | Error 12 | {message} 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default ErrorDisplay; 20 | -------------------------------------------------------------------------------- /nextjs/src/components/loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import CircularProgress from '@mui/material/CircularProgress'; 3 | import Box from '@mui/material/Box'; 4 | 5 | const Loading = () => { 6 | return ( 7 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default Loading; 21 | -------------------------------------------------------------------------------- /nextjs/src/components/scroll-top-top.js: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from 'react'; 2 | import IconButton from '@mui/material/IconButton'; 3 | import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; 4 | import useScrollTrigger from '@mui/material/useScrollTrigger'; 5 | import Zoom from '@mui/material/Zoom'; 6 | 7 | const ScrollToTop = ({children}) => { 8 | // Function to check if the page is scrolled down 9 | // The threshold value has been set to 10 to make the button appear earlier than only at the bottom 10 | const trigger = useScrollTrigger({ 11 | disableHysteresis: true, 12 | threshold: 300, // This value determines when the button becomes visible as you scroll down 13 | }); 14 | 15 | // Function to handle the scroll to top action 16 | const handleClick = () => { 17 | window.scrollTo({ 18 | top: 0, 19 | behavior: 'smooth', 20 | }); 21 | }; 22 | 23 | // Listen for scroll events to potentially show the button 24 | useEffect(() => { 25 | const handleScroll = () => { 26 | // This logic is handled by `useScrollTrigger`, so no additional logic is added here 27 | // Keeping this might hint at potential future use or modifications 28 | }; 29 | 30 | window.addEventListener('scroll', handleScroll); 31 | 32 | return () => { 33 | window.removeEventListener('scroll', handleScroll); 34 | }; 35 | }, []); 36 | 37 | return ( 38 | <> 39 | {children} 40 | 41 |
43 | 44 | 45 | 46 |
47 |
48 | 49 | ); 50 | }; 51 | 52 | export default ScrollToTop; 53 | -------------------------------------------------------------------------------- /nextjs/src/components/warp-effects.js: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import React, {useEffect, useState} from 'react'; 3 | 4 | const animations = { 5 | fadeIn: 'fadeIn 0.8s ease-out forwards', 6 | fadeOut: 'fadeOut 0.8s ease-out forwards', 7 | softSlideInLeft: 'softSlideInLeft 0.6s ease-in-out forwards', 8 | softSlideInRight: 'softSlideInRight 0.6s ease-in-out forwards', 9 | softSlideInUp: 'softSlideInUp 0.6s ease-in-out forwards', 10 | softSlideInDown: 'softSlideInDown 0.6s ease-in-out forwards', 11 | gentleZoomIn: 'gentleZoomIn 0.6s ease-in-out forwards', 12 | gentleZoomOut: 'gentleZoomOut 0.8s ease-in-out forwards', 13 | softRotateIn: 'softRotateIn 1s ease-in-out forwards', 14 | softBounceIn: 'softBounceIn 1s ease-in-out forwards', 15 | fadeInLeft: 'fadeInLeft 0.8s ease-in-out forwards', 16 | fadeInRight: 'fadeInRight 0.8s ease-in-out forwards', 17 | fadeInUp: 'fadeInUp 0.8s ease-in-out forwards', 18 | fadeInDown: 'fadeInDown 0.8s ease-in-out forwards', 19 | crossFadeIn: 'crossFadeIn 1.2s ease-in-out forwards', 20 | quickFlipInX: 'quickFlipInX 0.5s ease-in-out forwards', 21 | quickFlipInY: 'quickFlipInY 0.5s ease-in-out forwards', 22 | fadeSlideDown: 'fadeSlideDown 0.8s ease-in-out forwards', 23 | pulse: 'pulse 1.5s ease-in-out infinite', 24 | }; 25 | 26 | const WrapperEffects = styled.div` 27 | display: ${({ isVisible }) => (isVisible ? 'block' : 'none')}; 28 | animation: ${({ effect }) => animations[effect] || 'none'}; 29 | will-change: transform, opacity; 30 | 31 | @keyframes fadeInVisible { 32 | to { 33 | opacity: 1; 34 | } 35 | } 36 | @keyframes fadeIn { 37 | from { 38 | opacity: 0.5; 39 | } 40 | to { 41 | opacity: 1; 42 | } 43 | } 44 | 45 | @keyframes fadeOut { 46 | from { 47 | opacity: 1; 48 | } 49 | to { 50 | opacity: 0; 51 | } 52 | } 53 | 54 | @keyframes softSlideInLeft { 55 | from { 56 | transform: translateX(-200px); 57 | opacity: 0; 58 | } 59 | to { 60 | transform: translateX(0); 61 | opacity: 1; 62 | } 63 | } 64 | 65 | @keyframes softSlideInRight { 66 | from { 67 | transform: translateX(100px); 68 | opacity: 0; 69 | } 70 | to { 71 | transform: translateX(0); 72 | opacity: 1; 73 | } 74 | } 75 | 76 | @keyframes softSlideInUp { 77 | from { 78 | transform: translateY(100px); 79 | opacity: 0; 80 | } 81 | to { 82 | transform: translateY(0); 83 | opacity: 1; 84 | } 85 | } 86 | 87 | @keyframes softSlideInDown { 88 | from { 89 | transform: translateY(-150px); 90 | opacity: 0; 91 | } 92 | to { 93 | transform: translateY(0); 94 | opacity: 1; 95 | } 96 | } 97 | 98 | @keyframes gentleZoomIn { 99 | from { 100 | transform: scale(0.9); 101 | opacity: 0; 102 | } 103 | to { 104 | transform: scale(1); 105 | opacity: 1; 106 | } 107 | } 108 | 109 | @keyframes gentleZoomOut { 110 | from { 111 | transform: scale(1.5); 112 | opacity: 1; 113 | } 114 | to { 115 | transform: scale(1); 116 | opacity: 0; 117 | } 118 | } 119 | 120 | @keyframes softRotateIn { 121 | from { 122 | transform: rotate(-360deg); 123 | opacity: 0; 124 | } 125 | to { 126 | transform: rotate(0deg); 127 | opacity: 1; 128 | } 129 | } 130 | 131 | @keyframes softBounceIn { 132 | 0%, 20%, 50%, 80%, 100% { 133 | transform: translateY(0); 134 | } 135 | 40% { 136 | transform: translateY(-30px); 137 | } 138 | 60% { 139 | transform: translateY(-15px); 140 | } 141 | } 142 | 143 | @keyframes fadeInLeft { 144 | from { 145 | opacity: 0; 146 | transform: translateX(-20px); 147 | } 148 | to { 149 | opacity: 1; 150 | transform: translateX(0); 151 | } 152 | } 153 | 154 | @keyframes fadeInRight { 155 | from { 156 | opacity: 0; 157 | transform: translateX(20px); 158 | } 159 | to { 160 | opacity: 1; 161 | transform: translateX(0); 162 | } 163 | } 164 | 165 | @keyframes fadeInUp { 166 | from { 167 | opacity: 0; 168 | transform: translateY(20px); 169 | } 170 | to { 171 | opacity: 1; 172 | transform: translateY(0); 173 | } 174 | } 175 | 176 | @keyframes fadeInDown { 177 | from { 178 | opacity: 0; 179 | transform: translateY(-20px); 180 | } 181 | to { 182 | opacity: 1; 183 | transform: translateY(0); 184 | } 185 | } 186 | 187 | @keyframes crossFadeIn { 188 | 0% { 189 | opacity: 0; 190 | } 191 | 50% { 192 | opacity: 0.5; 193 | } 194 | 100% { 195 | opacity: 1; 196 | } 197 | } 198 | 199 | @keyframes quickFlipInX { 200 | from { 201 | opacity: 0; 202 | transform: rotateX(-90deg); 203 | } 204 | to { 205 | opacity: 1; 206 | transform: rotateX(0deg); 207 | } 208 | } 209 | 210 | @keyframes quickFlipInY { 211 | from { 212 | opacity: 0; 213 | transform: rotateY(-90deg); 214 | } 215 | to { 216 | opacity: 1; 217 | transform: rotateY(0deg); 218 | } 219 | } 220 | 221 | @keyframes fadeSlideDown { 222 | from { 223 | opacity: 0; 224 | transform: translateY(-30px); 225 | } 226 | to { 227 | opacity: 1; 228 | transform: translateY(0); 229 | } 230 | } 231 | 232 | @keyframes pulse { 233 | 0%, 100% { 234 | transform: scale(1); 235 | } 236 | 50% { 237 | transform: scale(1.05); 238 | } 239 | } 240 | `; 241 | 242 | const WarpEffect = ({ children, effect, pageProps }) => { 243 | const [isVisible, setIsVisible] = useState(false); 244 | 245 | // Effect for handling animations on pageProps change 246 | useEffect(() => { 247 | // Trigger the animation by first hiding the component, then revealing it 248 | // This approach ensures the animation plays on initial mount and page changes. 249 | function triggerAnimation() { 250 | setIsVisible(false); 251 | // Using a timeout to ensure there's a clear distinction between the hide and show states 252 | setTimeout(() => setIsVisible(true), 10); // Small delay to reset the animation 253 | } 254 | 255 | triggerAnimation(); 256 | 257 | // Optional: If you want to reset the animation before it starts, you can listen for changes in specific props 258 | }, [pageProps]); // Assuming pageProps change signifies a new page 259 | 260 | return ( 261 | 262 | {children} 263 | 264 | ); 265 | }; 266 | 267 | export default WarpEffect; -------------------------------------------------------------------------------- /nextjs/src/hooks/use-authenticated-route.js: -------------------------------------------------------------------------------- 1 | // useAuthenticatedRoute.js 2 | import React from 'react'; 3 | import {useRouter} from 'next/router'; 4 | import Loading from "@/components/loading"; 5 | import {useAuth} from "@/api/auth/auth-context"; // Adjust the import path as necessary 6 | 7 | const useAuthenticatedRoute = (WrappedComponent, redirectUrl = '/login') => { 8 | return function ProtectedRoute(props) { 9 | const {isAuthenticated, isLoading} = useAuth(); 10 | const router = useRouter(); 11 | 12 | React.useEffect(() => { 13 | if (!isLoading && !isAuthenticated) { 14 | // noinspection JSIgnoredPromiseFromCall 15 | router.push(redirectUrl); 16 | } 17 | }, [isAuthenticated, isLoading, router, redirectUrl]); 18 | 19 | if (isLoading) { 20 | return ; 21 | } 22 | 23 | return ; 24 | }; 25 | }; 26 | 27 | export default useAuthenticatedRoute; 28 | -------------------------------------------------------------------------------- /nextjs/src/hooks/use-popover.js: -------------------------------------------------------------------------------- 1 | import {useCallback, useRef, useState} from 'react'; 2 | 3 | export const usePopover = () => { 4 | const [open, setOpen] = useState(false); 5 | const anchorRef = useRef(null); 6 | 7 | const handleOpen = useCallback((event) => { 8 | // Set the current button as the anchor element for the popover 9 | anchorRef.current = event.currentTarget; 10 | setOpen(true); 11 | }, []); 12 | 13 | const handleClose = useCallback(() => { 14 | setOpen(false); 15 | }, []); 16 | 17 | return { 18 | open, 19 | setOpen, 20 | anchorRef, 21 | handleOpen, 22 | handleClose 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /nextjs/src/pages/404.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Box from '@mui/material/Box'; 3 | import Typography from '@mui/material/Typography'; 4 | import Button from '@mui/material/Button'; 5 | import Link from 'next/link'; 6 | 7 | const Custom404 = () => { 8 | return ( 9 | 19 | 20 | 404 - Page Not Found 21 | 22 | 23 | Oops! The page you are looking for has disappeared. 24 | 25 | 26 | 27 | 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default Custom404; 37 | -------------------------------------------------------------------------------- /nextjs/src/pages/_app.js: -------------------------------------------------------------------------------- 1 | import '@/styles/globals.css' 2 | import React, {useCallback} from 'react'; 3 | import {Container, CssBaseline, ThemeProvider} from '@mui/material'; 4 | import Menu from "@/sections/header/menu"; 5 | import {menuItems} from "@/sections/header/menu-items"; 6 | import WrapperEffects from "@/components/warp-effects"; 7 | import {Toaster} from "react-hot-toast"; 8 | import Footer from "@/sections/footer/footer"; 9 | import {AuthProvider} from "@/api/auth/auth-context"; 10 | import {QueryClient, QueryClientProvider} from "react-query"; 11 | import {darkTheme} from "@/theme/dark-theme"; 12 | import {loadSlim} from "tsparticles-slim"; 13 | import {Particles} from "react-tsparticles"; 14 | import ScrollToTop from "@/components/scroll-top-top"; // if you are going to use `loadSlim`, install the "tsparticles-slim" package too. 15 | 16 | const queryClient = new QueryClient(); 17 | 18 | function getVersion() { 19 | // Assuming package.json is located in the public folder 20 | const { version } = require('../../package.json'); 21 | return version; 22 | } 23 | 24 | function MyApp({Component, pageProps}) { 25 | const particlesInit = useCallback(async engine => { 26 | await loadSlim(engine); 27 | }, []); 28 | 29 | return ( 30 | 31 | 32 | 33 | 34 | 35 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 |