├── .example.env ├── .gitignore ├── ReadME.md ├── alembic.ini ├── requirements.txt └── src ├── __init__.py ├── app ├── __init__.py ├── celery_jobs.py ├── config.py ├── database.py ├── main.py └── utils │ ├── __init__.py │ ├── db_utils.py │ ├── mailer_util.py │ ├── models_utils.py │ ├── schemas_utils.py │ ├── slugger.py │ └── token.py ├── auth ├── __init__.py ├── auth_repository.py ├── auth_router.py ├── auth_service.py ├── models.py ├── oauth.py └── schemas.py ├── migrations ├── README ├── __init__.py ├── env.py ├── script.py.mako └── versions │ ├── 80f9e1cc8879_organization_table.py │ └── b59eb2cd4e8c_user_table.py ├── organization ├── __init__.py ├── models.py ├── org_repository.py ├── org_router.py ├── org_service.py ├── pipes │ ├── __init__.py │ └── org_dep.py └── schemas.py ├── permissions ├── __init__.py └── org_permissions.py ├── templates └── user │ └── verification.html └── tests ├── __init__.py ├── conftest.py ├── test_auth ├── __init__.py └── test_auth.py └── test_orgs ├── __init__.py └── test_org.py /.example.env: -------------------------------------------------------------------------------- 1 | NAME=saas- 2 | USERNAME= 3 | PASSWORD= 4 | HOSTNAME= 5 | PORT=5432 6 | 7 | ACCESS_SECRET_KEY= 8 | REFRESH_SECRET_KEY= 9 | ACCESS_TIME_EXP= 10 | REFRESH_TIME_EXP= 11 | ALGORITHM= 12 | FRONTEND_URL= 13 | 14 | 15 | MAIL_USERNAME= 16 | MAIL_PASSWORD= 17 | MAIL_FROM= 18 | MAIL_PORT= 19 | MAIL_SERVER= 20 | MAIL_FROM_NAME= 21 | 22 | 23 | SHOULD_TEST=False -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | venv 3 | .env 4 | .pytest_cache 5 | .idea/ -------------------------------------------------------------------------------- /ReadME.md: -------------------------------------------------------------------------------- 1 | # FastAPI SAAS Template 2 | FastAPI is a web framework which can be applied to various problems. Building Subscription As a Service application is one use case. 3 | 4 | As such I left the need to create a template that provides a lot of base/out of the box features such as 5 | Configs, Migrations, Authentication, Authorization and Permissions. 6 | 7 | The core of the Application is a User Auth module and Organizations. 8 | 9 | Organization is a team with team members. Invites can be sent via Links which can be revoked. 10 | 11 | Permissions include freemium block checks as dependencies, an example is limiting the number of organizations a freemium user can have to 2. 12 | 13 | 14 | Admin right actions: Only Admin can perform such actions. 15 | Strict Member Actions. 16 | 17 | 18 | 19 | # Installation 20 | Click on use Template at the top righthand corner of the screen which would create a repository for you. 21 | 22 | After Cloning the repo we are down to usage. 23 | 24 | ## Usage 25 | 26 | first thing is to set up your virtual environment. 27 | 28 | By way of illustration I will provide snippets to help you setup. 29 | 30 | Ps. All commands below are terminal commands. 31 | 32 | 33 | 34 | creating a virtualenv via venv 35 | ``` 36 | python3 -m venv {name of your env} 37 | ``` 38 | 39 | To activate your venv 40 | 41 | ``` 42 | source {name of your venv}/bin/activate 43 | ``` 44 | 45 | 46 | 47 | Please create a dotenv file for your environment variables. An example environment variable is provided. This file is called ```.example.env```. 48 | 49 | Installing Requirements can be done by using this command. 50 | 51 | 52 | ``` 53 | pip install -r requirements.txt 54 | 55 | ``` 56 | 57 | Migrating DB with Alembic 58 | 59 | ``` 60 | alembic upgrade heads 61 | ``` 62 | 63 | Starting your Server 64 | 65 | ``` 66 | uvicorn src.app.main:app --reload 67 | 68 | ``` 69 | 70 | ## PostMan Collection. 71 | 72 | I create a postman collection that can be forked for testing. here -> https://documenter.getpostman.com/view/17138168/2s93CGRbQg 73 | 74 | 75 | ## Contributing 76 | There are few things that need to be worked on, they are 77 | 78 | 1. Testing: I will be writting a comprehensive overwhelmingly encompassing test case which will cover every case especially edge cases. I would love to learn to write good test so I am willing to partner with engineers on this. 79 | 80 | Intialy I was looking forward to writting test that handles edge cases oe exception block in place, however for now I am testing best case. 81 | 82 | What are the best cases? These are the apporiate response during the req-res cycle without any HTTPEception. 83 | 84 | 85 | PR are welcome, For major changes, please open an issue first 86 | to discuss what you would like to change. 87 | 88 | 89 | 90 | ## License 91 | 92 | 93 | MIT License 94 | 95 | Copyright (c) [2023] [FastAPI SAAS Template] 96 | 97 | Permission is hereby granted, free of charge, to any person obtaining a copy 98 | of this software and associated documentation files (the "Software"), to deal 99 | in the Software without restriction, including without limitation the rights 100 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 101 | copies of the Software, and to permit persons to whom the Software is 102 | furnished to do so, subject to the following conditions: 103 | 104 | The above copyright notice and this permission notice shall be included in all 105 | copies or substantial portions of the Software. 106 | 107 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 108 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 109 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 110 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 111 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 112 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 113 | SOFTWARE. -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = src/migrations 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 10 | # for all available tokens 11 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 12 | 13 | # sys.path path, will be prepended to sys.path if present. 14 | # defaults to the current working directory. 15 | prepend_sys_path = . 16 | 17 | # timezone to use when rendering the date within the migration file 18 | # as well as the filename. 19 | # If specified, requires the python-dateutil library that can be 20 | # installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to dateutil.tz.gettz() 22 | # leave blank for localtime 23 | # timezone = 24 | 25 | # max length of characters to apply to the 26 | # "slug" field 27 | # truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to migrations/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:migrations/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 53 | 54 | # the output encoding used when revision files 55 | # are written from script.py.mako 56 | # output_encoding = utf-8 57 | 58 | sqlalchemy.url = 59 | 60 | 61 | [post_write_hooks] 62 | # post_write_hooks defines scripts or Python functions that are run 63 | # on newly generated revision scripts. See the documentation for further 64 | # detail and examples 65 | 66 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 67 | # hooks = black 68 | # black.type = console_scripts 69 | # black.entrypoint = black 70 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 71 | 72 | # Logging configuration 73 | [loggers] 74 | keys = root,sqlalchemy,alembic 75 | 76 | [handlers] 77 | keys = console 78 | 79 | [formatters] 80 | keys = generic 81 | 82 | [logger_root] 83 | level = WARN 84 | handlers = console 85 | qualname = 86 | 87 | [logger_sqlalchemy] 88 | level = WARN 89 | handlers = 90 | qualname = sqlalchemy.engine 91 | 92 | [logger_alembic] 93 | level = INFO 94 | handlers = 95 | qualname = alembic 96 | 97 | [handler_console] 98 | class = StreamHandler 99 | args = (sys.stderr,) 100 | level = NOTSET 101 | formatter = generic 102 | 103 | [formatter_generic] 104 | format = %(levelname)-5.5s [%(name)s] %(message)s 105 | datefmt = %H:%M:%S 106 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiosmtplib==2.0.1 2 | alembic==1.9.2 3 | amqp==5.1.1 4 | anyio==3.6.2 5 | attrs==22.2.0 6 | bcrypt==4.0.1 7 | billiard==3.6.4.0 8 | black==23.1.0 9 | blinker==1.5 10 | celery==5.2.7 11 | certifi==2022.12.7 12 | click==8.1.3 13 | click-didyoumean==0.3.0 14 | click-plugins==1.1.1 15 | click-repl==0.2.0 16 | dnspython==2.3.0 17 | ecdsa==0.18.0 18 | email-validator==1.3.1 19 | fastapi==0.89.1 20 | fastapi-mail==1.2.5 21 | greenlet==2.0.2 22 | h11==0.14.0 23 | httpcore==0.16.3 24 | httptools==0.5.0 25 | httpx==0.23.3 26 | idna==3.4 27 | iniconfig==2.0.0 28 | itsdangerous==2.1.2 29 | Jinja2==3.1.2 30 | kombu==5.2.4 31 | Mako==1.2.4 32 | MarkupSafe==2.1.2 33 | mypy-extensions==1.0.0 34 | orjson==3.8.5 35 | packaging==23.0 36 | passlib==1.7.4 37 | pathspec==0.11.0 38 | platformdirs==2.6.2 39 | pluggy==1.0.0 40 | prompt-toolkit==3.0.36 41 | psycopg2-binary==2.9.5 42 | pyasn1==0.4.8 43 | pydantic==1.10.4 44 | pytest==7.2.1 45 | pytest-asyncio==0.20.3 46 | pytest-timeout==2.1.0 47 | python-dotenv==0.21.1 48 | python-jose==3.3.0 49 | python-multipart==0.0.5 50 | pytz==2022.7.1 51 | PyYAML==6.0 52 | rfc3986==1.5.0 53 | rsa==4.9 54 | six==1.16.0 55 | sniffio==1.3.0 56 | SQLAlchemy==2.0.1 57 | starlette==0.22.0 58 | typing_extensions==4.4.0 59 | ujson==5.7.0 60 | uvicorn==0.20.0 61 | uvloop==0.17.0 62 | vine==5.0.0 63 | watchfiles==0.18.1 64 | wcwidth==0.2.6 65 | websockets==10.4 66 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philipokiokio/FastAPI_SAAS_Template/e1579bb23c7f3de8b84511db0db1b6399da443b4/src/__init__.py -------------------------------------------------------------------------------- /src/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philipokiokio/FastAPI_SAAS_Template/e1579bb23c7f3de8b84511db0db1b6399da443b4/src/app/__init__.py -------------------------------------------------------------------------------- /src/app/celery_jobs.py: -------------------------------------------------------------------------------- 1 | from celery import Celery 2 | from celery.schedules import crontab 3 | 4 | job = Celery("SAAS Template", broker="redis://localhost:6379/0") 5 | job.conf.enable_utc = True 6 | -------------------------------------------------------------------------------- /src/app/config.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from src.app.utils.schemas_utils import AbstractSettings, BaseModel, EmailStr 4 | 5 | 6 | class DBSettings(AbstractSettings): 7 | """Database Settings 8 | 9 | Args: 10 | AbstractSettings (_type_): inherits Core settings. 11 | """ 12 | 13 | name: str 14 | username: str 15 | password: str 16 | hostname: str 17 | port: int 18 | 19 | 20 | class AuthSettings(AbstractSettings): 21 | """Authentication Settings 22 | 23 | Args: 24 | AbstractSettings (_type_): inherits Core settings. 25 | """ 26 | 27 | access_secret_key: str 28 | refresh_secret_key: str 29 | access_time_exp: int 30 | refresh_time_exp: int 31 | algorithm: str 32 | frontend_url: str 33 | 34 | 35 | class MailSettings(AbstractSettings): 36 | """Mail Settings 37 | 38 | Args: 39 | AbstractSettings (_type_): inherits Core settings. 40 | """ 41 | 42 | mail_username: str 43 | mail_password: str 44 | mail_from: EmailStr 45 | mail_port: int 46 | mail_server: str 47 | mail_from_name: str 48 | 49 | 50 | class TestSettings(AbstractSettings): 51 | should_test: Optional[bool] 52 | 53 | 54 | db_settings = DBSettings() 55 | auth_settings = AuthSettings() 56 | mail_settings = MailSettings() 57 | test_status = TestSettings() 58 | -------------------------------------------------------------------------------- /src/app/database.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker 3 | 4 | # application import config. 5 | from src.app.config import db_settings 6 | 7 | # DB URL for connection 8 | SQLALCHEMY_DATABASE_URL = f"postgresql://{db_settings.username}:{db_settings.password}@{db_settings.hostname}:{db_settings.port}/{db_settings.name}" 9 | 10 | # Creating DB engine 11 | engine = create_engine(SQLALCHEMY_DATABASE_URL) 12 | 13 | # Creating and Managing session. 14 | SessionFactory = sessionmaker(autocommit=False, autoflush=False, bind=engine) 15 | 16 | # Domain Modelling Dependency 17 | Base = declarative_base() 18 | 19 | 20 | TEST_SQLALCHEMY_DATABASE_URL = SQLALCHEMY_DATABASE_URL + "_test" 21 | test_engine = create_engine(TEST_SQLALCHEMY_DATABASE_URL) 22 | TestFactory = sessionmaker(autoflush=False, autocommit=False, bind=test_engine) 23 | print("Database is Ready!") 24 | 25 | 26 | def get_test_db(): 27 | print("Test Database is Ready!") 28 | test_db = TestFactory() 29 | 30 | try: 31 | yield test_db 32 | finally: 33 | test_db.close() 34 | -------------------------------------------------------------------------------- /src/app/main.py: -------------------------------------------------------------------------------- 1 | # python imports 2 | from typing import List 3 | 4 | # fastapi imports 5 | from fastapi import Depends, FastAPI, status 6 | from fastapi.middleware.cors import CORSMiddleware 7 | 8 | # application imports 9 | from src.auth.auth_router import user_router 10 | from src.organization.org_router import org_router 11 | 12 | # fastapi initialization 13 | app = FastAPI() 14 | 15 | 16 | # CORS Middleware 17 | origins: List = [] 18 | 19 | 20 | app.add_middleware( 21 | CORSMiddleware, 22 | allow_origins=origins, 23 | allow_credentials=True, 24 | allow_methods=["*"], 25 | allow_headers=["*"], 26 | ) 27 | 28 | 29 | # Routers from the application 30 | app.include_router(user_router) 31 | app.include_router(org_router) 32 | 33 | 34 | # root of the server 35 | @app.get("/", status_code=status.HTTP_200_OK) 36 | def root() -> dict: 37 | return {"message": "Welcome to FastAPI SAAS Template", "docs": "/docs"} 38 | -------------------------------------------------------------------------------- /src/app/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philipokiokio/FastAPI_SAAS_Template/e1579bb23c7f3de8b84511db0db1b6399da443b4/src/app/utils/__init__.py -------------------------------------------------------------------------------- /src/app/utils/db_utils.py: -------------------------------------------------------------------------------- 1 | from passlib.context import CryptContext 2 | from src.app.database import SessionFactory 3 | # Password Hashing 4 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 5 | 6 | 7 | def verify_password(hashed_password: str, plain_password: str) -> bool: 8 | """Verify Password 9 | 10 | Args: 11 | hashed_password (str): Stored passwoed in the DB compared with the raw string 12 | plain_password (str): Raw string to be compared. 13 | 14 | Returns: 15 | _type_: Bool 16 | """ 17 | return pwd_context.verify(plain_password, hashed_password) 18 | 19 | 20 | def hash_password(password: str) -> str: 21 | """Hashes Password string 22 | 23 | Args: 24 | password (str): String 25 | 26 | Returns: 27 | str: Hashed string 28 | """ 29 | return pwd_context.hash(password) 30 | 31 | 32 | 33 | def get_db(): 34 | db = SessionFactory() 35 | 36 | try: 37 | yield db 38 | except: 39 | db.rollback() 40 | 41 | finally: 42 | db.close() -------------------------------------------------------------------------------- /src/app/utils/mailer_util.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List 3 | 4 | # 3rd party imports 5 | from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType 6 | 7 | # application imports 8 | from src.app.config import EmailStr, mail_settings 9 | 10 | # conf configuration 11 | conf = ConnectionConfig( 12 | MAIL_USERNAME=mail_settings.mail_username, 13 | MAIL_PASSWORD=mail_settings.mail_password, 14 | MAIL_FROM=mail_settings.mail_from, 15 | MAIL_PORT=mail_settings.mail_port, 16 | MAIL_SERVER=mail_settings.mail_server, 17 | MAIL_FROM_NAME=mail_settings.mail_from_name, 18 | MAIL_STARTTLS=True, 19 | MAIL_SSL_TLS=False, 20 | USE_CREDENTIALS=True, 21 | VALIDATE_CERTS=False, 22 | TEMPLATE_FOLDER=Path(__file__).parent.parent.parent / "templates/", 23 | ) 24 | 25 | 26 | async def send_mail( 27 | recieptients: List[EmailStr], subject: str, body: dict, template_name: str 28 | ) -> bool: 29 | """Send Mail 30 | 31 | Args: 32 | recieptients (List[EmailStr]): Array of Email 33 | subject (str): Mail Subject 34 | body (dict): an Hashmap/Dict of data 35 | template_name (str): the template name 36 | 37 | Returns: 38 | bool: status on success or failure. 39 | """ 40 | message = MessageSchema( 41 | subject=subject, 42 | recipients=recieptients, 43 | template_body=body, 44 | subtype=MessageType.html, 45 | ) 46 | fm = FastMail(conf) 47 | 48 | status = False 49 | try: 50 | await fm.send_message(message, template_name=template_name) 51 | status = True 52 | except Exception as e: 53 | pass 54 | return status 55 | -------------------------------------------------------------------------------- /src/app/utils/models_utils.py: -------------------------------------------------------------------------------- 1 | # 3rd party import 2 | from sqlalchemy import TIMESTAMP, Column, Integer, text 3 | 4 | # application imports 5 | from src.app.database import Base 6 | 7 | 8 | class AbstractModel(Base): 9 | """Base Models 10 | 11 | Args: 12 | Base (_type_): Inherits Base from SQLAlchemy and specifies columns for inheritance. 13 | """ 14 | 15 | __abstract__ = True 16 | 17 | id = Column(Integer, nullable=False, primary_key=True) 18 | date_created = Column(TIMESTAMP(timezone=True), server_default=text("NOW()")) 19 | date_updated = Column(TIMESTAMP(timezone=True), server_default=text("NOW()")) 20 | -------------------------------------------------------------------------------- /src/app/utils/schemas_utils.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from pydantic import BaseModel, BaseSettings, EmailStr 4 | 5 | 6 | class AbstractModel(BaseModel): 7 | """Schema Models 8 | 9 | Args: 10 | BaseModel (_type_): Inherits from Pydantic and specifies Config 11 | """ 12 | 13 | class Config: 14 | orm_mode = True 15 | use_enum_values = True 16 | 17 | 18 | class AbstractSettings(BaseSettings): 19 | """Settings Models 20 | 21 | Args: 22 | BaseModel (_type_): Inherits from Pydantic and specifies Config 23 | """ 24 | 25 | class Config: 26 | env_file = ".env" 27 | 28 | 29 | class ResponseModel(AbstractModel): 30 | """Base Response Models 31 | 32 | Args: 33 | BaseModel (_type_): Inherits from Pydantic and specifies Config 34 | """ 35 | 36 | message: str 37 | status: int 38 | 39 | 40 | class RoleOptions(Enum): 41 | admin = "Admin" 42 | member = "Member" 43 | 44 | 45 | class User(AbstractModel): 46 | """User Schema Models 47 | 48 | Args: 49 | BaseModel (_type_): Inherits from Pydantic and specifies Config 50 | """ 51 | 52 | first_name: str 53 | last_name: str 54 | email: EmailStr 55 | -------------------------------------------------------------------------------- /src/app/utils/slugger.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | 4 | def slug_gen() -> str: 5 | return uuid4().hex 6 | -------------------------------------------------------------------------------- /src/app/utils/token.py: -------------------------------------------------------------------------------- 1 | # Token generation 3rd party generation 2 | from itsdangerous.exc import BadSignature 3 | from itsdangerous.url_safe import URLSafeSerializer, URLSafeTimedSerializer 4 | 5 | # application imports 6 | from src.app.config import auth_settings 7 | 8 | # Creating timed and untimed data serializers 9 | tokens = URLSafeSerializer( 10 | f"{auth_settings.access_secret_key}+{auth_settings.refresh_secret_key}" 11 | ) 12 | invite_tokens = URLSafeTimedSerializer(f"{auth_settings.refresh_secret_key}") 13 | 14 | 15 | def gen_token(data: str): 16 | # Token is serialized 17 | toks = tokens.dumps(data) 18 | 19 | return toks 20 | 21 | 22 | def auth_token(data: str): 23 | # Timed token serializer 24 | return invite_tokens.dumps(data) 25 | 26 | 27 | def retrieve_token(token: str): 28 | # return data from the token 29 | try: 30 | data = tokens.loads(token) 31 | except BadSignature: 32 | return None 33 | return data 34 | 35 | 36 | def auth_retrieve_token(token: str): 37 | # return data from the token 38 | 39 | try: 40 | data = invite_tokens.loads(token, max_age=300) 41 | except BadSignature: 42 | return None 43 | return data 44 | -------------------------------------------------------------------------------- /src/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philipokiokio/FastAPI_SAAS_Template/e1579bb23c7f3de8b84511db0db1b6399da443b4/src/auth/__init__.py -------------------------------------------------------------------------------- /src/auth/auth_repository.py: -------------------------------------------------------------------------------- 1 | # Pydantic imports 2 | from pydantic import EmailStr 3 | 4 | # application import 5 | from src.auth.models import RefreshToken, User 6 | from sqlalchemy.orm import Session 7 | 8 | 9 | class UserRepo: 10 | def __init__(self, db: Session) -> None: 11 | self.db = db 12 | 13 | def base_query(self): 14 | # Base Query for DB calls 15 | return self.db.query(User) 16 | 17 | def get_user(self, email: EmailStr): 18 | # get user by email 19 | return self.base_query().filter(User.email.icontains(email)).first() 20 | 21 | def create(self, user_create: any) -> User: 22 | # create a new user 23 | new_user = User(**user_create.dict()) 24 | new_user.is_premium = False 25 | self.db.add(new_user) 26 | self.db.commit() 27 | self.db.refresh(new_user) 28 | return new_user 29 | 30 | def delete(self, user: User) -> bool: 31 | # delete user 32 | resp = False 33 | 34 | self.db.delete(user) 35 | self.db.commit() 36 | 37 | quick_check = self.base_query().filter(User.email == user.email).first() 38 | if not quick_check: 39 | resp = True 40 | return resp 41 | 42 | def update(self, user: User): 43 | # update user 44 | updated_user = user 45 | self.db.commit() 46 | self.db.refresh(updated_user) 47 | return updated_user 48 | 49 | 50 | class TokenRepo: 51 | def __init__(self, db: Session) -> None: 52 | self.db = db 53 | 54 | def base_query(self): 55 | # base query for refresh token 56 | return self.db.query(RefreshToken) 57 | 58 | def create_token(self, refresh_token: str, user_id: int) -> RefreshToken: 59 | # store refresh token 60 | refresh_token = RefreshToken(token=refresh_token, user_id=user_id) 61 | self.db.add(refresh_token) 62 | self.db.commit() 63 | self.db.refresh(refresh_token) 64 | 65 | return refresh_token 66 | 67 | def get_token(self, user_id: int): 68 | # filter by user_id 69 | return self.base_query().filter(RefreshToken.user_id == user_id).first() 70 | 71 | def get_token_by_tok(self, token: str): 72 | # filter by token 73 | return self.base_query().filter(RefreshToken.token == token).first() 74 | 75 | def update_token(self, update_token) -> RefreshToken: 76 | # update token 77 | self.db.commit() 78 | self.db.refresh(update_token) 79 | return update_token 80 | 81 | 82 | # Instatiating the Classes. 83 | 84 | user_repo = UserRepo 85 | token_repo = TokenRepo 86 | -------------------------------------------------------------------------------- /src/auth/auth_router.py: -------------------------------------------------------------------------------- 1 | # framework imports 2 | from fastapi import APIRouter, Depends, status 3 | from fastapi.security.oauth2 import OAuth2PasswordRequestForm 4 | 5 | # application imports 6 | from src.auth import schemas 7 | from src.auth.auth_service import user_service 8 | from src.auth.oauth import get_current_user, verify_refresh_token 9 | from sqlalchemy.orm import Session 10 | from src.app.utils.db_utils import get_db 11 | 12 | # API Router 13 | user_router = APIRouter(prefix="/api/v1/auth", tags=["User Authentication"]) 14 | 15 | 16 | @user_router.post( 17 | "/register/", 18 | status_code=status.HTTP_201_CREATED, 19 | response_model=schemas.MessageUserResponse, 20 | ) 21 | async def register(user_create: schemas.user_create, db:Session = Depends(get_db)): 22 | """Registration of User 23 | 24 | Args: 25 | user_create (schemas.user_create): { 26 | "first_name", "last_name","email", "password" 27 | } 28 | 29 | Returns: 30 | _type_: response 31 | """ 32 | new_user = await user_service(db).register(user_create) 33 | return { 34 | "message": "Registration Successful", 35 | "data": new_user, 36 | "status": status.HTTP_201_CREATED, 37 | } 38 | 39 | 40 | @user_router.post( 41 | "/login/", 42 | status_code=status.HTTP_200_OK, 43 | response_model=schemas.MessageLoginResponse, 44 | ) 45 | def login(login_user: OAuth2PasswordRequestForm = Depends(),db:Session = Depends(get_db)): 46 | """Login 47 | 48 | Args: 49 | login_user (OAuth2PasswordRequestForm, optional):username, password 50 | 51 | Returns: 52 | _type_: user 53 | """ 54 | user_login = user_service(db).login(login_user) 55 | return user_login 56 | 57 | 58 | @user_router.get( 59 | "/me/", status_code=status.HTTP_200_OK, response_model=schemas.MessageUserResponse 60 | ) 61 | def logged_in_user(current_user: dict = Depends(get_current_user)): 62 | """ME 63 | 64 | Args: 65 | current_user (dict, optional): _description_. Defaults to Depends(get_current_user): User data. 66 | 67 | Returns: 68 | _type_: User 69 | """ 70 | return {"message": "Me Data", "data": current_user, "status": status.HTTP_200_OK} 71 | 72 | 73 | @user_router.patch( 74 | "/update/", 75 | status_code=status.HTTP_200_OK, 76 | response_model=schemas.MessageUserResponse, 77 | ) 78 | def update_user( 79 | update_user: schemas.UserUpdate, current_user: dict = Depends(get_current_user),db:Session = Depends(get_db) 80 | ): 81 | """Update User 82 | 83 | Args: 84 | update_user (schemas.UserUpdate): all user fields. 85 | current_user (dict, optional): _description_. Defaults to Depends(get_current_user): Logged In User. 86 | 87 | Returns: 88 | _type_: resp 89 | """ 90 | update_user = user_service(db).update_user(update_user, current_user) 91 | 92 | return { 93 | "message": "User Updated Successfully", 94 | "data": update_user, 95 | "status": status.HTTP_200_OK, 96 | } 97 | 98 | 99 | @user_router.delete("/delete/", status_code=status.HTTP_204_NO_CONTENT) 100 | def delete_user(current_user: dict = Depends(get_current_user),db:Session = Depends(get_db)): 101 | """Delete User 102 | 103 | Args: 104 | current_user (dict, optional): _description_. Defaults to Depends(get_current_user): Logged In User 105 | 106 | Returns: 107 | _type_: 204 108 | """ 109 | user_service(db).delete(current_user) 110 | return {"status": status.HTTP_204_NO_CONTENT} 111 | 112 | 113 | @user_router.get("/refresh/", status_code=status.HTTP_200_OK) 114 | def get_new_token(new_access_token: str = Depends(verify_refresh_token)): 115 | """New Access token 116 | 117 | Args: 118 | new_access_token (str, optional): _description_. Defaults to Depends(verify_refresh_token): Gets Access token based on refresh token. 119 | 120 | Returns: 121 | _type_: _description_ 122 | """ 123 | return { 124 | "message": "New access token created successfully", 125 | "token": new_access_token, 126 | "status": status.HTTP_200_OK, 127 | } 128 | 129 | 130 | @user_router.patch( 131 | "/change-password/", 132 | status_code=status.HTTP_200_OK, 133 | response_model=schemas.MessageUserResponse, 134 | ) 135 | def change_password( 136 | password_data: schemas.ChangePassword, 137 | current_user: dict = Depends(get_current_user), 138 | db:Session = Depends(get_db) 139 | ): 140 | """Change Password 141 | 142 | Args: 143 | password_data (schemas.ChangePassword): {password, old_password} 144 | current_user (dict, optional): _description_. Defaults to Depends(get_current_user): Logged In User 145 | 146 | Returns: 147 | _type_: response 148 | """ 149 | resp = user_service(db).change_password(current_user, password_data) 150 | return resp 151 | 152 | 153 | @user_router.post("/password-reset/complete/{token}/", status_code=status.HTTP_200_OK) 154 | def password_reset_complete(token: str, password_data: schemas.PasswordData,db:Session = Depends(get_db)): 155 | """Password Reset 156 | 157 | Args: 158 | token (str): _description_: Password reset token 159 | password_data (schemas.PasswordData): {password} 160 | 161 | Returns: 162 | _type_: resp 163 | """ 164 | resp = user_service(db).password_reset_complete(token, password_data) 165 | return resp 166 | 167 | 168 | @user_router.post("/reset-password/", status_code=status.HTTP_200_OK) 169 | async def reset_password(password_data: schemas.TokenData,db:Session = Depends(get_db)): 170 | """Reset Password 171 | 172 | Args: 173 | password_data (schemas.TokenData): eemail 174 | 175 | Returns: 176 | _type_: response 177 | """ 178 | resp = await user_service(db).password_reset(password_data.email) 179 | return resp 180 | 181 | 182 | @user_router.post("/resend-account-verification/", status_code=status.HTTP_200_OK) 183 | async def resend_account_verification(email_data: schemas.TokenData,db:Session = Depends(get_db)): 184 | """Resend Account Verification 185 | 186 | Args: 187 | email_data (schemas.TokenData): email 188 | 189 | Returns: 190 | _type_: resp 191 | """ 192 | resp = await user_service(db).resend_verification_token(email_data.email) 193 | return resp 194 | 195 | 196 | @user_router.post("/account-verification/{token}/", status_code=status.HTTP_200_OK) 197 | async def account_verification(token: str,db:Session = Depends(get_db)): 198 | """Account Verification 199 | 200 | Args: 201 | token (str): email verification token 202 | 203 | Returns: 204 | _type_: response 205 | """ 206 | resp = user_service(db).account_verification_complete(token) 207 | return resp 208 | -------------------------------------------------------------------------------- /src/auth/auth_service.py: -------------------------------------------------------------------------------- 1 | # Framework Imports 2 | from fastapi import HTTPException, status 3 | from fastapi.encoders import jsonable_encoder 4 | from fastapi.security.oauth2 import OAuth2PasswordRequestForm 5 | 6 | # application imports 7 | from src.app.utils.db_utils import hash_password, verify_password 8 | from src.app.utils.mailer_util import send_mail 9 | from src.app.utils.token import auth_retrieve_token, auth_settings, auth_token 10 | from src.auth import schemas 11 | from src.auth.auth_repository import token_repo, user_repo 12 | from src.auth.models import RefreshToken, User 13 | from src.auth.oauth import ( 14 | create_access_token, 15 | create_refresh_token, 16 | credential_exception, 17 | ) 18 | 19 | 20 | class UserService: 21 | def __init__(self, db): 22 | # Initializing Repositories 23 | self.db = db 24 | self.user_repo = user_repo(self.db) 25 | self.token_repo = token_repo(self.db) 26 | 27 | async def register(self, user: schemas.user_create) -> User: 28 | # checking if user exists. 29 | user_check = self.user_repo.get_user(user.email) 30 | 31 | # raise an Exception if user exists. 32 | if user_check: 33 | raise HTTPException( 34 | detail="This User has an account", 35 | status_code=status.HTTP_400_BAD_REQUEST, 36 | ) 37 | # password hashing 38 | user.password = hash_password(user.password) 39 | # creating new user 40 | new_user = self.user_repo.create(user) 41 | # create new access token 42 | token = auth_token(new_user.email) 43 | # mail data inserted in to the template 44 | mail_data = { 45 | "first_name": new_user.first_name, 46 | "url": f"{auth_settings.frontend_url}auth/verification/{token}/", 47 | } 48 | # mail title 49 | mail_title = "Verify your Account" 50 | template_pointer = "user/verification.html" 51 | # send mail 52 | await send_mail([new_user.email], mail_title, mail_data, template_pointer) 53 | 54 | return new_user 55 | 56 | def login(self, user: OAuth2PasswordRequestForm) -> schemas.LoginResponse: 57 | # check if user exists. 58 | user_check = self.user_repo.get_user(user.username) 59 | # raise exception if there is no user 60 | if not user_check: 61 | raise HTTPException( 62 | detail="User does not exist", status_code=status.HTTP_400_BAD_REQUEST 63 | ) 64 | # verify that the password is correct. 65 | pass_hash_check = verify_password(user_check.password, user.password) 66 | # raise credential error 67 | if not pass_hash_check: 68 | credential_exception() 69 | # if user is not verified raise exception 70 | if user_check.is_verified is False: 71 | raise HTTPException( 72 | detail="User Account is not verified", 73 | status_code=status.HTTP_401_UNAUTHORIZED, 74 | ) 75 | # create Access and Refresh Token 76 | tokenizer= {"id": user_check.id, "email": user_check.email} 77 | access_token = create_access_token(tokenizer) 78 | refresh_token = create_refresh_token(tokenizer) 79 | # check if there is a previously existing refresh token 80 | token_check = self.token_repo.get_token(user_check.id) 81 | # if token update token column 82 | if token_check: 83 | token_check.token = refresh_token 84 | self.token_repo.update_token(token_check) 85 | else: 86 | # create new token data 87 | self.token_repo.create_token(refresh_token, user_check.id) 88 | 89 | # validating data via the DTO 90 | refresh_token_ = {"token": refresh_token, "header": "Refresh-Tok"} 91 | login_resp = schemas.LoginResponse( 92 | data=user_check, 93 | refresh_token=refresh_token_, 94 | ) 95 | # DTO response 96 | resp = { 97 | "message": "Login Successful", 98 | "data": login_resp, 99 | "access_token": access_token, 100 | "token_type": "bearer", 101 | "status": status.HTTP_200_OK, 102 | } 103 | return resp 104 | 105 | def update_user(self, update_user: schemas.UserUpdate, user: User) -> User: 106 | # update user 107 | update_user_dict = update_user.dict(exclude_unset=True) 108 | 109 | for key, value in update_user_dict.items(): 110 | setattr(user, key, value) 111 | 112 | return self.user_repo.update(user) 113 | 114 | def delete(self, user: User) -> bool: 115 | # delete user 116 | return self.user_repo.delete(user) 117 | 118 | async def password_reset(self, user_email: str): 119 | # check if user exist. 120 | user = self.user_repo.get_user(user_email) 121 | # raise Exception if user does not exist. 122 | if not user: 123 | raise HTTPException( 124 | detail="User does not exist", status_code=status.HTTP_404_NOT_FOUND 125 | ) 126 | # create Timed Token 127 | token = auth_token(user.email) 128 | # mail data 129 | mail_data = { 130 | "first_name": user.first_name, 131 | "url": f"{auth_settings.frontend_url}/auth/verification/{token}/", 132 | } 133 | # mail subject 134 | mail_title = "Reset your Password" 135 | template_pointer = "/user/verification.html" 136 | # send mail 137 | mail_status = await send_mail( 138 | [user.email], mail_title, mail_data, template_pointer 139 | ) 140 | # response based on the success or failure of sending mail 141 | if mail_status: 142 | return { 143 | "message": "Reset Mail sent successfully", 144 | "status": status.HTTP_200_OK, 145 | "mail_status": mail_status, 146 | } 147 | else: 148 | return { 149 | "message": "Reset Mail was not sent", 150 | "status": status.HTTP_400_BAD_REQUEST, 151 | "mail_status": mail_status, 152 | } 153 | 154 | def password_reset_complete(self, token: str, password_data: schemas.PasswordData): 155 | # extract data from timed token 156 | data = auth_retrieve_token(token) 157 | # if data is None raise Exception 158 | if data is None: 159 | raise HTTPException( 160 | detail="Token has expired.", status_code=status.HTTP_409_CONFLICT 161 | ) 162 | # check for user based on tokjen data 163 | 164 | user = self.user_repo.get_user(data) 165 | # raise exception if user does not exist. 166 | if not user: 167 | raise HTTPException( 168 | detail="User does not exist", status_code=status.HTTP_404_NOT_FOUND 169 | ) 170 | # update newly set password in hash 171 | user.password = hash_password(password_data.password) 172 | # update user 173 | self.user_repo.update(user) 174 | return { 175 | "message": "User password set successfully", 176 | "status": status.HTTP_200_OK, 177 | } 178 | 179 | def change_password(self, user: User, password_data: schemas.ChangePassword): 180 | # verify oldpassword is saved in the DB 181 | password_check = verify_password(user.password, password_data.old_password) 182 | # if not True raise Exception 183 | if not password_check: 184 | raise HTTPException( 185 | detail="Old Password does not corelate.", 186 | status_code=status.HTTP_400_BAD_REQUEST, 187 | ) 188 | # hash new password 189 | user.password = hash_password(password_data.password) 190 | # update user 191 | user = self.user_repo.update(user) 192 | # return user 193 | return { 194 | "message": "Password changed successfully", 195 | "data": user, 196 | "status": status.HTTP_200_OK, 197 | } 198 | 199 | async def resend_verification_token(self, user_email: str): 200 | # get user 201 | user = self.user_repo.get_user(user_email) 202 | # if not user raise Exception 203 | if not user: 204 | raise HTTPException( 205 | detail="User does not exist", status_code=status.HTTP_404_NOT_FOUND 206 | ) 207 | # create timed token 208 | token = auth_token(user.email) 209 | # mail data for the template 210 | mail_data = { 211 | "first_name": user.first_name, 212 | "url": f"{auth_settings.frontend_url}/auth/verification/{token}/", 213 | } 214 | # mail subject 215 | mail_title = "Verify your Account" 216 | template_pointer = "/user/verification.html" 217 | # send email 218 | mail_status = await send_mail( 219 | [user.email], mail_title, mail_data, template_pointer 220 | ) 221 | # if mail sent send this else 222 | if mail_status: 223 | return { 224 | "message": "Account Verification Mail sent successfully", 225 | "status": status.HTTP_200_OK, 226 | "mail_status": mail_status, 227 | } 228 | else: 229 | return { 230 | "message": "Account Verification Mail was not sent", 231 | "status": status.HTTP_400_BAD_REQUEST, 232 | "mail_status": mail_status, 233 | } 234 | 235 | def account_verification_complete(self, token: str): 236 | # validate token 237 | data = auth_retrieve_token(token) 238 | # raise token Error if None 239 | if data is None: 240 | raise HTTPException( 241 | detail="Token has expired.", status_code=status.HTTP_409_CONFLICT 242 | ) 243 | # get user based on the data 244 | user = self.user_repo.get_user(data) 245 | # if user does not exists raise Exception 246 | if not user: 247 | raise HTTPException( 248 | detail="User does not exist", status_code=status.HTTP_404_NOT_FOUND 249 | ) 250 | # update user verification flag. 251 | user.is_verified = True 252 | self.user_repo.update(user) 253 | return { 254 | "message": "User Account is verified successfully", 255 | "status": status.HTTP_200_OK, 256 | } 257 | 258 | 259 | # Instanting the UserService class 260 | user_service = UserService 261 | -------------------------------------------------------------------------------- /src/auth/models.py: -------------------------------------------------------------------------------- 1 | # 3rd party imports 2 | from sqlalchemy import Boolean, Column, ForeignKey, String, text 3 | from sqlalchemy.orm import relationship 4 | 5 | # application imports 6 | from src.app.utils.models_utils import AbstractModel 7 | 8 | 9 | class User(AbstractModel): 10 | # User Table 11 | __tablename__ = "users" 12 | first_name = Column(String, nullable=False) 13 | last_name = Column(String, nullable=False) 14 | email = Column(String, unique=True) 15 | password = Column(String, nullable=False) 16 | is_verified = Column(Boolean, nullable=False, server_default=text("false")) 17 | is_premium = Column(Boolean, nullable=False, server_default=text("false")) 18 | 19 | 20 | class RefreshToken(AbstractModel): 21 | # Refresh Token Table 22 | __tablename__ = "user_refresh_token" 23 | user_id = Column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False) 24 | token = Column(String, nullable=False) 25 | user = relationship("User", passive_deletes=True) 26 | -------------------------------------------------------------------------------- /src/auth/oauth.py: -------------------------------------------------------------------------------- 1 | # python import 2 | from datetime import datetime, timedelta 3 | 4 | # framework imports 5 | from fastapi import Depends, Header, HTTPException, status 6 | from fastapi.security.oauth2 import OAuth2PasswordBearer 7 | from sqlalchemy.orm import Session 8 | 9 | # JWT imports 10 | from jose import JWTError, jwt 11 | 12 | # Apoplication imports 13 | from src.app.config import auth_settings 14 | from src.app.utils.db_utils import get_db 15 | from src.auth.auth_repository import token_repo, user_repo 16 | from src.auth.schemas import TokenData 17 | 18 | # OAUTH Login Endpoint 19 | oauth_schemes = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login/") 20 | 21 | 22 | # AUTH SECRETS AND TIME LIMITS 23 | access_secret_key = auth_settings.access_secret_key 24 | refresh_secret_key = auth_settings.refresh_secret_key 25 | access_time_exp = auth_settings.access_time_exp 26 | refresh_time_exp = auth_settings.refresh_time_exp 27 | Algorithm = auth_settings.algorithm 28 | 29 | 30 | def create_access_token(data: dict) -> str: 31 | # Create Access Token 32 | to_encode = data.copy() 33 | expire = datetime.now() + timedelta(minutes=access_time_exp) 34 | to_encode["exp"] = expire 35 | encode_jwt = jwt.encode(to_encode, access_secret_key, algorithm=Algorithm) 36 | return encode_jwt 37 | 38 | 39 | def create_refresh_token(data: dict) -> str: 40 | # Create Refresh Token 41 | to_encode = data.copy() 42 | expire = datetime.now() + timedelta(minutes=refresh_time_exp) 43 | to_encode["exp"] = expire 44 | refresh_encode_jwt = jwt.encode(to_encode, refresh_secret_key, algorithm=Algorithm) 45 | return refresh_encode_jwt 46 | 47 | 48 | def credential_exception(): 49 | # Throw Auth Exception 50 | raise HTTPException( 51 | detail="Could not validate credentials", 52 | status_code=status.HTTP_401_UNAUTHORIZED, 53 | headers={"WWW-Authenticate": "Bearer"}, 54 | ) 55 | 56 | 57 | def refresh_exception(): 58 | # Throw Refresh Exception 59 | 60 | raise HTTPException( 61 | detail="Could not validate refresh credential", 62 | status_code=status.HTTP_401_UNAUTHORIZED, 63 | headers={"Refresh-Tok": "token"}, 64 | ) 65 | 66 | 67 | def verify_refresh_token(refresh_tok: str = Header(),db:Session = Depends(get_db)) -> str: 68 | # Verify Refresh Token 69 | try: 70 | decoded_data = jwt.decode(refresh_tok, refresh_secret_key, algorithms=Algorithm) 71 | email = decoded_data.get("email") 72 | if not email: 73 | raise refresh_exception() 74 | token_data = TokenData(email=email) 75 | except JWTError: 76 | raise refresh_exception() 77 | 78 | refresh_token_check = token_repo(db).get_token_by_tok(refresh_tok) 79 | 80 | if not refresh_token_check: 81 | refresh_exception() 82 | 83 | if refresh_token_check.user.email != token_data.email: 84 | refresh_exception() 85 | 86 | return create_access_token(decoded_data) 87 | 88 | 89 | def get_current_user(token: str = Depends(oauth_schemes), db:Session = Depends(get_db)): 90 | # Verify Access token and return User 91 | try: 92 | decode_data = jwt.decode(token, access_secret_key, algorithms=Algorithm) 93 | email = decode_data.get("email") 94 | if email is None: 95 | credential_exception() 96 | 97 | token_data = TokenData(email=email) 98 | except JWTError: 99 | credential_exception() 100 | 101 | user_check = user_repo(db).get_user(token_data.email) 102 | 103 | if not user_check: 104 | credential_exception() 105 | 106 | return user_check 107 | -------------------------------------------------------------------------------- /src/auth/schemas.py: -------------------------------------------------------------------------------- 1 | # python imports 2 | from datetime import datetime 3 | from typing import Optional 4 | 5 | # 3rd party 6 | from pydantic import EmailStr 7 | 8 | # application import 9 | from src.app.utils.schemas_utils import AbstractModel, ResponseModel 10 | 11 | 12 | # Email DTO (Used for token verification) 13 | class TokenData(AbstractModel): 14 | email: EmailStr 15 | 16 | 17 | # Create new user 18 | class user_create(AbstractModel): 19 | first_name: str 20 | last_name: str 21 | email: EmailStr 22 | password: str 23 | is_verified: bool = False 24 | 25 | 26 | # ORM response 27 | class UserResponse(AbstractModel): 28 | first_name: str 29 | last_name: str 30 | email: EmailStr 31 | date_created: datetime 32 | 33 | 34 | # Password Data for password reset 35 | class PasswordData(AbstractModel): 36 | password: str 37 | 38 | 39 | # Password data for Change Password 40 | class ChangePassword(PasswordData): 41 | old_password: str 42 | 43 | 44 | # User Update DTO 45 | class UserUpdate(AbstractModel): 46 | first_name: Optional[str] 47 | last_name: Optional[str] 48 | 49 | 50 | # Req-Res Response DTO 51 | class MessageUserResponse(ResponseModel): 52 | data: UserResponse 53 | 54 | 55 | # Token DTO 56 | class Token(AbstractModel): 57 | token: str 58 | 59 | 60 | # Refresh Token DTO 61 | class RefreshToken(Token): 62 | header: str 63 | 64 | 65 | # Login ORM Response 66 | class LoginResponse(AbstractModel): 67 | data: UserResponse 68 | 69 | refresh_token: RefreshToken 70 | 71 | 72 | # Req-Res Login Response 73 | class MessageLoginResponse(ResponseModel): 74 | data: LoginResponse 75 | access_token: str 76 | token_type: str 77 | -------------------------------------------------------------------------------- /src/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /src/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philipokiokio/FastAPI_SAAS_Template/e1579bb23c7f3de8b84511db0db1b6399da443b4/src/migrations/__init__.py -------------------------------------------------------------------------------- /src/migrations/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from alembic import context 4 | from sqlalchemy import engine_from_config, pool 5 | 6 | from src.app.database import Base, db_settings 7 | 8 | # this is the Alembic Config object, which provides 9 | # access to the values within the .ini file in use. 10 | config = context.config 11 | 12 | 13 | config.set_main_option( 14 | "sqlalchemy.url", 15 | f"postgresql+psycopg2://{db_settings.username}:{db_settings.password}@{db_settings.hostname}:{db_settings.port}/{db_settings.name}", 16 | ) 17 | # Interpret the config file for Python logging. 18 | # This line sets up loggers basically. 19 | if config.config_file_name is not None: 20 | fileConfig(config.config_file_name) 21 | 22 | # add your model's MetaData object here 23 | # for 'autogenerate' support 24 | # from myapp import mymodel 25 | # target_metadata = mymodel.Base.metadata 26 | target_metadata = Base.metadata 27 | 28 | # other values from the config, defined by the needs of env.py, 29 | # can be acquired: 30 | # my_important_option = config.get_main_option("my_important_option") 31 | # ... etc. 32 | 33 | 34 | def run_migrations_offline() -> None: 35 | """Run migrations in 'offline' mode. 36 | 37 | This configures the context with just a URL 38 | and not an Engine, though an Engine is acceptable 39 | here as well. By skipping the Engine creation 40 | we don't even need a DBAPI to be available. 41 | 42 | Calls to context.execute() here emit the given string to the 43 | script output. 44 | 45 | """ 46 | url = config.get_main_option("sqlalchemy.url") 47 | context.configure( 48 | url=url, 49 | target_metadata=target_metadata, 50 | literal_binds=True, 51 | dialect_opts={"paramstyle": "named"}, 52 | ) 53 | 54 | with context.begin_transaction(): 55 | context.run_migrations() 56 | 57 | 58 | def run_migrations_online() -> None: 59 | """Run migrations in 'online' mode. 60 | 61 | In this scenario we need to create an Engine 62 | and associate a connection with the context. 63 | 64 | """ 65 | connectable = engine_from_config( 66 | config.get_section(config.config_ini_section), 67 | prefix="sqlalchemy.", 68 | poolclass=pool.NullPool, 69 | ) 70 | 71 | with connectable.connect() as connection: 72 | context.configure(connection=connection, target_metadata=target_metadata) 73 | 74 | with context.begin_transaction(): 75 | context.run_migrations() 76 | 77 | 78 | if context.is_offline_mode(): 79 | run_migrations_offline() 80 | else: 81 | run_migrations_online() 82 | -------------------------------------------------------------------------------- /src/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade() -> None: 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade() -> None: 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /src/migrations/versions/80f9e1cc8879_organization_table.py: -------------------------------------------------------------------------------- 1 | """Organization Table 2 | 3 | Revision ID: 80f9e1cc8879 4 | Revises: b59eb2cd4e8c 5 | Create Date: 2023-02-05 17:37:00.078276 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "80f9e1cc8879" 13 | down_revision = "b59eb2cd4e8c" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade() -> None: 19 | op.create_table( 20 | "organization", 21 | sa.Column("id", sa.Integer), 22 | sa.Column("name", sa.String(), nullable=False), 23 | sa.Column("slug", sa.String(), nullable=False), 24 | sa.Column("created_by", sa.Integer(), nullable=True), 25 | sa.Column("revoke_link", sa.Boolean(), server_default=sa.text("false")), 26 | sa.Column( 27 | "date_created", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()") 28 | ), 29 | sa.Column( 30 | "date_updated", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()") 31 | ), 32 | sa.PrimaryKeyConstraint("id"), 33 | sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), 34 | ) 35 | 36 | op.create_table( 37 | "organization_member", 38 | sa.Column("id", sa.Integer), 39 | sa.Column("org_id", sa.Integer(), nullable=False), 40 | sa.Column("member_id", sa.Integer(), nullable=False), 41 | sa.Column("role", sa.String(), nullable=False), 42 | sa.Column( 43 | "date_created", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()") 44 | ), 45 | sa.Column( 46 | "date_updated", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()") 47 | ), 48 | sa.PrimaryKeyConstraint("id"), 49 | sa.ForeignKeyConstraint(["org_id"], ["organization.id"], ondelete="CASCADE"), 50 | sa.ForeignKeyConstraint(["member_id"], ["users.id"], ondelete="CASCADE"), 51 | ) 52 | pass 53 | 54 | 55 | def downgrade() -> None: 56 | op.drop_constraint( 57 | "organization_member_member_id_fkey", table_name="organization_member" 58 | ) 59 | op.drop_constraint( 60 | "organization_member_org_id_fkey", table_name="organization_member" 61 | ) 62 | op.drop_table("organization") 63 | op.drop_table("organization_member") 64 | pass 65 | -------------------------------------------------------------------------------- /src/migrations/versions/b59eb2cd4e8c_user_table.py: -------------------------------------------------------------------------------- 1 | """User Table 2 | 3 | Revision ID: b59eb2cd4e8c 4 | Revises: 5 | Create Date: 2023-02-05 13:56:20.314855 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "b59eb2cd4e8c" 13 | down_revision = None 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade() -> None: 19 | op.create_table( 20 | "users", 21 | sa.Column("id", sa.Integer), 22 | sa.Column("first_name", sa.String(), nullable=False), 23 | sa.Column("last_name", sa.String(), nullable=False), 24 | sa.Column("email", sa.String, unique=True, nullable=False), 25 | sa.Column("password", sa.String, nullable=False), 26 | sa.Column("is_verified", sa.Boolean, nullable=False), 27 | sa.Column("is_premium", sa.Boolean, nullable=False), 28 | sa.Column( 29 | "date_created", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()") 30 | ), 31 | sa.Column( 32 | "date_updated", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()") 33 | ), 34 | sa.PrimaryKeyConstraint("id"), 35 | ) 36 | op.create_table( 37 | "user_refresh_token", 38 | sa.Column("id", sa.Integer), 39 | sa.Column("user_id", sa.Integer, nullable=False), 40 | sa.Column("token", sa.String, nullable=False), 41 | sa.Column( 42 | "date_created", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()") 43 | ), 44 | sa.Column( 45 | "date_updated", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()") 46 | ), 47 | sa.PrimaryKeyConstraint("id"), 48 | sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), 49 | ) 50 | pass 51 | 52 | 53 | def downgrade() -> None: 54 | op.drop_constraint("user_refresh_token_user_id_fkey", "user_refresh_token") 55 | op.drop_table("user_refresh_token") 56 | op.drop_table("users") 57 | pass 58 | -------------------------------------------------------------------------------- /src/organization/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philipokiokio/FastAPI_SAAS_Template/e1579bb23c7f3de8b84511db0db1b6399da443b4/src/organization/__init__.py -------------------------------------------------------------------------------- /src/organization/models.py: -------------------------------------------------------------------------------- 1 | # 3rd party imports 2 | from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, text 3 | from sqlalchemy.orm import relationship 4 | 5 | # Application import 6 | from src.app.utils.models_utils import AbstractModel 7 | from src.auth.models import User 8 | 9 | 10 | # Organization Table. 11 | class Organization(AbstractModel): 12 | __tablename__ = "organization" 13 | name = Column(String, nullable=False) 14 | slug = Column(String, nullable=False) 15 | created_by = Column( 16 | Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True 17 | ) 18 | revoke_link = Column(Boolean, server_default=text("false")) 19 | creator = relationship("User") 20 | org_member = relationship( 21 | "OrgMember", back_populates="org", cascade="all, delete-orphan" 22 | ) 23 | 24 | 25 | # Organization Member Table. 26 | class OrgMember(AbstractModel): 27 | __tablename__ = "organization_member" 28 | org_id = Column( 29 | Integer, ForeignKey("organization.id", ondelete="CASCADE"), nullable=False 30 | ) 31 | member_id = Column( 32 | Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False 33 | ) 34 | role = Column(String, nullable=False) 35 | member = relationship("User") 36 | org = relationship("Organization") 37 | -------------------------------------------------------------------------------- /src/organization/org_repository.py: -------------------------------------------------------------------------------- 1 | # application imports 2 | 3 | from src.organization.models import Organization, OrgMember 4 | from sqlalchemy.orm import Session 5 | 6 | class OrgRepo(): 7 | # org base query 8 | 9 | def __init__(self, db:Session ) -> None: 10 | self.db = db 11 | def base_query(self): 12 | return self.db.query(Organization) 13 | 14 | # check if Org exists. 15 | def check_org(self, name: str): 16 | return self.base_query().filter(Organization.name.ilike(name)).first() 17 | 18 | # get org by slug 19 | def get_org(self, slug: str): 20 | return self.base_query().filter(Organization.slug == slug).first() 21 | 22 | # get orgs that user is a member of. 23 | def get_user_orgs(self, user_id: int): 24 | return ( 25 | self.base_query() 26 | .filter(Organization.org_member.member_id.has(id=user_id)) 27 | .all() 28 | ) 29 | 30 | # all orgs created by a user 31 | def get_orgs_created_by_user(self, user_id: int): 32 | return self.base_query().filter(Organization.created_by == user_id).all() 33 | 34 | # return org_count and data 35 | def user_org_count_data(self, user_id: int): 36 | user_org = ( 37 | self.base_query() 38 | .filter(Organization.org_member.any(member_id=user_id)) 39 | .all() 40 | ) 41 | org_count = ( 42 | self.base_query() 43 | .filter(Organization.org_member.any(member_id=user_id)) 44 | .count() 45 | ) 46 | return user_org, org_count 47 | 48 | # create Org 49 | def create_org(self, org_create: dict): 50 | new_org = Organization(**org_create) 51 | self.db.add(new_org) 52 | self.db.commit() 53 | self.db.refresh(new_org) 54 | return new_org 55 | 56 | # update Org 57 | def update_org(self, org_update: Organization): 58 | self.db.commit() 59 | self.db.refresh(org_update) 60 | return org_update 61 | 62 | # delete Org 63 | def delete_org(self, org: Organization): 64 | self.db.delete(org) 65 | self.db.commit() 66 | 67 | 68 | class OrgMemberRepo(): 69 | 70 | def __init__(self, db:Session) -> None: 71 | self.db = db 72 | # base query 73 | def base_query(self): 74 | return self.db.query(OrgMember) 75 | 76 | # get org members based on org_id and member id 77 | def get_org_member(self, org_id: int, id: int): 78 | return ( 79 | self.base_query() 80 | .filter( 81 | OrgMember.org_id == org_id, 82 | OrgMember.id == id, 83 | ) 84 | .first() 85 | ) 86 | 87 | # get membership data based on org_id and user_id 88 | def get_org_member_by_user_id(self, org_id: int, user_id: int): 89 | return ( 90 | self.base_query() 91 | .filter( 92 | OrgMember.org_id == org_id, 93 | OrgMember.member_id == user_id, 94 | ) 95 | .first() 96 | ) 97 | 98 | # get all org members by org_id 99 | def get_org_members(self, org_id: int): 100 | return ( 101 | self.base_query() 102 | .filter( 103 | OrgMember.org_id == org_id, 104 | ) 105 | .all() 106 | ) 107 | 108 | # create org member 109 | def create_org_member(self, org_member: dict): 110 | new_org_member = OrgMember(**org_member) 111 | self.db.add(new_org_member) 112 | self.db.commit() 113 | self.db.refresh(new_org_member) 114 | return new_org_member 115 | 116 | # update org memeber 117 | def update_org_member(self, org_update): 118 | self.db.commit() 119 | self.db.refresh(org_update) 120 | return org_update 121 | 122 | # delete member 123 | def delete_org_member(self, org): 124 | self.db.delete(org) 125 | self.db.commit() 126 | 127 | 128 | org_repo = OrgRepo 129 | org_member_repo = OrgMemberRepo 130 | -------------------------------------------------------------------------------- /src/organization/org_router.py: -------------------------------------------------------------------------------- 1 | # framework import 2 | from fastapi import APIRouter, Depends, status 3 | 4 | # application imports 5 | from src.auth.models import User 6 | from src.auth.oauth import get_current_user 7 | from src.organization import schemas 8 | from src.organization.org_service import org_service 9 | from src.organization.pipes import org_dep 10 | from src.app.utils.db_utils import get_db 11 | from sqlalchemy.orm import Session 12 | 13 | 14 | # org router 15 | org_router = APIRouter(prefix="/api/v1/org", tags=["Organization and Org Members"]) 16 | 17 | 18 | @org_router.post( 19 | "/create/", 20 | status_code=status.HTTP_201_CREATED, 21 | response_model=schemas.MessageOrgResp, 22 | ) 23 | def create_org( 24 | create_workspace: schemas.OrgCreate, 25 | current_user: User = Depends(org_dep.premium_ulimited_orgs),db:Session = Depends(get_db) 26 | ): 27 | """Create Org 28 | 29 | Args: 30 | create_workspace (schemas.OrgCreate): data 31 | current_user (User, optional): _description_. Defaults to Depends(org_dep.premium_ulimited_orgs): Premium check. 32 | 33 | Returns: 34 | _type_: Resp 35 | """ 36 | resp = org_service(db).create_org(current_user.id, create_workspace) 37 | 38 | return resp 39 | 40 | 41 | @org_router.get( 42 | "s/", 43 | status_code=status.HTTP_200_OK, 44 | response_model=schemas.MessageListOrgResp, 45 | ) 46 | def get_orgs(current_user: User = Depends(get_current_user),db:Session = Depends(get_db)): 47 | """Get Orgs 48 | 49 | Args: 50 | current_user (User, optional): _description_. Defaults to Depends(get_current_user): Logged in User. 51 | 52 | Returns: 53 | _type_: resp 54 | """ 55 | resp = org_service(db).get_user_org(current_user.id) 56 | 57 | return resp 58 | 59 | 60 | @org_router.get( 61 | "/{org_slug}/", 62 | status_code=status.HTTP_200_OK, 63 | response_model=schemas.MessageOrgResp, 64 | ) 65 | def get_org(org_slug: str, current_user: User = Depends(org_dep.member_dep),db:Session = Depends(get_db)): 66 | """Get Org 67 | 68 | Args: 69 | org_slug (str): slug 70 | current_user (User, optional): _description_. Defaults to Depends(org_dep.member_dep): Logged in Org Member 71 | 72 | Returns: 73 | _type_: resp 74 | """ 75 | resp = org_service(db).get_org(org_slug) 76 | 77 | return resp 78 | 79 | 80 | @org_router.patch( 81 | "/{org_slug}/update/", 82 | response_model=schemas.MessageOrgResp, 83 | status_code=status.HTTP_200_OK, 84 | ) 85 | def org_update( 86 | org_slug: str, 87 | update_org: schemas.OrgUpdate, 88 | current_user: User = Depends(org_dep.admin_rights_dep),db:Session = Depends(get_db) 89 | ): 90 | """Org Update 91 | 92 | Args: 93 | org_slug (str): Slug 94 | update_org (schemas.OrgUpdate): Data 95 | current_user (User, optional): _description_. Defaults to Depends(org_dep.admin_rights_dep): Loggeed inn userr with write access. 96 | 97 | Returns: 98 | _type_: resp 99 | """ 100 | resp = org_service(db).update_org(org_slug, update_org) 101 | 102 | return resp 103 | 104 | 105 | @org_router.delete( 106 | "/{org_slug}/delete/", 107 | status_code=status.HTTP_204_NO_CONTENT, 108 | ) 109 | def org_delete(org_slug: str, current_user: User = Depends(org_dep.admin_rights_dep),db:Session = Depends(get_db)): 110 | """Delete Organization 111 | 112 | Args: 113 | org_slug (str): Slug 114 | current_user (User, optional): _description_. Defaults to Depends(org_dep.admin_rights_dep)= Write access. 115 | 116 | Returns: 117 | _type_: 204 118 | """ 119 | org_service(db).delete_org(org_slug) 120 | 121 | return {"status": status.HTTP_204_NO_CONTENT} 122 | 123 | 124 | @org_router.post( 125 | "/{org_slug}/invite-link/gen/", 126 | status_code=status.HTTP_200_OK, 127 | response_model=schemas.InviteOrgResponse, 128 | ) 129 | def generate_org_invite_link( 130 | org_slug: str, 131 | role_data: schemas.UpdateOrgMember, 132 | current_user: User = Depends(org_dep.admin_rights_dep),db:Session = Depends(get_db) 133 | ): 134 | """GENERATE ORG LINK 135 | 136 | Args: 137 | org_slug (str): str 138 | role_data (schemas.UpdateOrgMember): role information 139 | current_user (User, optional): _description_. Defaults to Depends(org_dep.admin_rights_dep). 140 | 141 | Returns: 142 | _type_: resp 143 | """ 144 | resp = org_service(db).org_link_invite(org_slug, role_data.role) 145 | 146 | return resp 147 | 148 | 149 | @org_router.post( 150 | "/{org_slug}/revoke-link/", 151 | status_code=status.HTTP_200_OK, 152 | response_model=schemas.MessageOrgResp, 153 | ) 154 | def revoke_org(org_slug: str, current_user: User = Depends(org_dep.admin_rights_dep),db:Session = Depends(get_db)): 155 | """Revoke Org Access 156 | 157 | Args: 158 | org_slug (str): slug 159 | current_user (User, optional): _description_. Defaults to Depends(org_dep.admin_rights_dep): Logged in user with right access. 160 | 161 | Returns: 162 | _type_: resp 163 | """ 164 | resp = org_service(db).revoke_org_link(org_slug) 165 | return resp 166 | 167 | 168 | @org_router.post( 169 | "/join/", 170 | status_code=status.HTTP_200_OK, 171 | response_model=schemas.MessageOrgMembResp, 172 | ) 173 | def org_member_join(token: str, role_token: str, new_org_member: schemas.JoinOrg,db:Session = Depends(get_db)): 174 | """Join Org 175 | 176 | Args: 177 | token (str): str 178 | role_token (str): role token 179 | new_org_member (schemas.JoinOrg): data 180 | 181 | Returns: 182 | _type_: resp 183 | """ 184 | resp = org_service(db).join_org(token, role_token, new_org_member) 185 | 186 | return resp 187 | 188 | 189 | @org_router.get( 190 | "/{org_slug}/member/{member_id}/", 191 | status_code=status.HTTP_200_OK, 192 | response_model=schemas.MessageOrgMembResp, 193 | ) 194 | def get_org_member( 195 | org_slug: str, member_id: int, current_user: User = Depends(org_dep.member_dep),db:Session = Depends(get_db) 196 | ): 197 | """Get Org Member 198 | 199 | Args: 200 | org_slug (str): Slug 201 | member_id (int): Member id 202 | current_user (User, optional): _description_. Defaults to Depends(get_current_user): Logged in User a member of Org. 203 | 204 | Returns: 205 | _type_: resp 206 | """ 207 | resp = org_service(db).get_org_member(member_id, org_slug) 208 | 209 | return resp 210 | 211 | 212 | @org_router.get( 213 | "/{org_slug}/members/", 214 | status_code=status.HTTP_200_OK, 215 | response_model=schemas.MessageListOrgMemResp, 216 | ) 217 | def get_org_members(org_slug: str, current_user: User = Depends(org_dep.member_dep),db:Session = Depends(get_db)): 218 | """Get All ORg Members 219 | 220 | Args: 221 | org_slug (str): slug 222 | current_user (User, optional): _description_. Defaults to Depends(org_dep.member_dep): Logged in Member of Org. 223 | 224 | Returns: 225 | _type_: Resp 226 | """ 227 | resp = org_service(db).get_all_org_member(org_slug) 228 | 229 | return resp 230 | 231 | 232 | @org_router.patch( 233 | "/{org_slug}/member/{member_id}/update/", 234 | status_code=status.HTTP_200_OK, 235 | response_model=schemas.MessageOrgMembResp, 236 | ) 237 | def update_org_member( 238 | org_slug: str, 239 | member_id: int, 240 | update_org_member: schemas.UpdateOrgMember, 241 | current_user: User = Depends(org_dep.admin_rights_dep),db:Session = Depends(get_db) 242 | ): 243 | """_summary_ 244 | 245 | Args: 246 | org_slug (str): slug 247 | member_id (int): member id 248 | update_org_member (schemas.UpdateOrgMember): role data 249 | current_user (User, optional): _description_. Defaults to Depends(org_dep.admin_rights_dep). 250 | 251 | Returns: 252 | _type_: resp 253 | """ 254 | resp = org_service(db).update_org_member(org_slug, member_id, update_org_member) 255 | 256 | return resp 257 | 258 | 259 | @org_router.delete( 260 | "/{org_slug}/member/{member_id}/delete/", 261 | status_code=status.HTTP_204_NO_CONTENT 262 | ) 263 | def delete_workspace_member( 264 | org_slug: str, 265 | member_id: int, 266 | current_user: User = Depends(org_dep.admin_rights_dep),db:Session = Depends(get_db) 267 | ): 268 | """Remove from Organization 269 | 270 | Args: 271 | org_slug (str): Slug 272 | member_id (int): Member id 273 | current_user (User, optional): _description_. Defaults to Depends(org_dep.admin_rights_dep): Logged in User with right permissions. 274 | 275 | Returns: 276 | _type_: 204 277 | """ 278 | org_service(db).delete_org_member(org_slug, member_id) 279 | 280 | return {"status": status.HTTP_204_NO_CONTENT} 281 | 282 | 283 | @org_router.delete( 284 | "/{org_slug}/member/leave/", 285 | status_code=status.HTTP_200_OK, 286 | response_model=schemas.ResponseModel, 287 | ) 288 | def leave_workspace(org_slug: str, current_user: User = Depends(org_dep.member_dep),db:Session = Depends(get_db)): 289 | """Leave a Workspace 290 | 291 | Args: 292 | org_slug (str): org slug 293 | current_user (User, optional): _description_. Defaults to Depends(org_dep.member_dep)= Logged in User with the right permission. 294 | 295 | Returns: 296 | _type_: _description_ 297 | """ 298 | org_service(db).leave_org(org_slug, current_user) 299 | 300 | return {"status": status.HTTP_200_OK, "message": "Logged In User left Orgnizaton."} 301 | -------------------------------------------------------------------------------- /src/organization/org_service.py: -------------------------------------------------------------------------------- 1 | # Fastapi imports 2 | from fastapi import HTTPException, status 3 | from fastapi.encoders import jsonable_encoder 4 | 5 | # application imports 6 | from src.app.config import auth_settings 7 | from src.app.utils.schemas_utils import RoleOptions 8 | from src.app.utils.slugger import slug_gen 9 | from src.app.utils.token import gen_token, retrieve_token 10 | from src.auth.auth_repository import user_repo 11 | from src.organization import schemas 12 | from src.organization.models import Organization, OrgMember 13 | from src.organization.org_repository import org_member_repo, org_repo 14 | 15 | 16 | class OrgService: 17 | def __init__(self,db): 18 | # intializing repository 19 | self.db = db 20 | self.org_repo = org_repo(self.db) 21 | self.org_member_repo = org_member_repo(self.db) 22 | 23 | # orm call org 24 | def orm_call(self, org: Organization): 25 | org_ = org.__dict__ 26 | org_["creator"] = org.creator 27 | org_["members"] = org.org_member 28 | return org_ 29 | 30 | # orm call org member 31 | def member_orm_call(self, org_member: OrgMember): 32 | org_member_ = jsonable_encoder(org_member) 33 | org_member_["org"] = org_member.org 34 | org_member_["user"] = org_member.member 35 | return org_member_ 36 | 37 | def create_org( 38 | self, user_id: int, org_create: schemas.OrgCreate 39 | ) -> schemas.MessageOrgResp: 40 | # check org 41 | org_check = self.org_repo.check_org(org_create.name) 42 | if org_check: 43 | raise HTTPException( 44 | detail="Org exist", status_code=status.HTTP_409_CONFLICT 45 | ) 46 | # data mapping 47 | org_dict = org_create.dict() 48 | org_dict["slug"] = slug_gen()[:14] 49 | org_dict["created_by"] = user_id 50 | 51 | # create org 52 | org = self.org_repo.create_org(org_dict) 53 | # org member data mapping 54 | org_member_dict = { 55 | "org_id": org.id, 56 | "member_id": user_id, 57 | "role": RoleOptions.admin.value, 58 | } 59 | # create org member 60 | self.org_member_repo.create_org_member(org_member_dict) 61 | # org orm member 62 | org = self.orm_call(org) 63 | resp = { 64 | "message": "Org Created Successfully", 65 | "data": org, 66 | "status": status.HTTP_201_CREATED, 67 | } 68 | 69 | return resp 70 | 71 | def get_org(self, slug: str) -> schemas.MessageOrgResp: 72 | # chek for org 73 | org = self.org_repo.get_org(slug) 74 | if not org: 75 | raise HTTPException( 76 | detail="Org does not exists", 77 | status_code=status.HTTP_404_NOT_FOUND, 78 | ) 79 | 80 | # orm call 81 | org_ = self.orm_call(org) 82 | resp = { 83 | "message": "Org Returned", 84 | "data": org_, 85 | "status": status.HTTP_200_OK, 86 | } 87 | return resp 88 | 89 | def get_user_org(self, user_id: int) -> schemas.MessageListOrgResp: 90 | # all orgs a user belongs too 91 | user_orgs, _ = self.org_repo.user_org_count_data(user_id) 92 | # if not ORg raise HTTPException 93 | if not user_orgs: 94 | raise HTTPException( 95 | detail="User Does not have Orgs", 96 | status_code=status.HTTP_404_NOT_FOUND, 97 | ) 98 | 99 | # ORM call 100 | orgs = [] 101 | for user_org in user_orgs: 102 | orgs.append(self.orm_call(user_org)) 103 | 104 | resp = { 105 | "message": "User Orgs retrieved successfully", 106 | "data": orgs, 107 | "status": status.HTTP_200_OK, 108 | } 109 | return resp 110 | 111 | def update_org( 112 | self, slug: str, update_org: schemas.OrgUpdate 113 | ) -> schemas.MessageOrgResp: 114 | # check org 115 | org = self.org_repo.get_org(slug) 116 | if not org: 117 | raise HTTPException( 118 | detail="Org does not exists", 119 | status_code=status.HTTP_404_NOT_FOUND, 120 | ) 121 | # update Org 122 | org_update_ = update_org.dict(exclude_unset=True) 123 | # update org 124 | for key, value in org_update_.items(): 125 | setattr(org, key, value) 126 | 127 | org = self.org_repo.update_org(org) 128 | # orm call 129 | org_ = self.orm_call(org) 130 | resp = { 131 | "message": "Org Updated Successfully", 132 | "data": org_, 133 | "status": status.HTTP_200_OK, 134 | } 135 | return resp 136 | 137 | def delete_org(self, slug: str): 138 | # check org 139 | org = self.org_repo.get_org(slug) 140 | 141 | if not org: 142 | raise HTTPException( 143 | detail="Org does not exists", 144 | status_code=status.HTTP_404_NOT_FOUND, 145 | ) 146 | # delete org 147 | self.org_repo.delete_org(org) 148 | 149 | # raise HTTPException if not org 150 | def org_check(self, workspace): 151 | if not workspace: 152 | raise HTTPException( 153 | detail="Org does not exist", status_code=status.HTTP_404_NOT_FOUND 154 | ) 155 | 156 | def org_link_invite(self, slug: str, role: RoleOptions): 157 | # check org 158 | org = self.org_repo.get_org(slug) 159 | self.org_check(org) 160 | 161 | # generate tokens 162 | token = gen_token(org.slug) 163 | role_tok = gen_token(role) 164 | if org.revoke_link: 165 | org.revoke_link = False 166 | self.org_repo.update_org(org) 167 | 168 | name = org.name.split(" ") 169 | name = "-".join(name) 170 | # generate link 171 | invite_link = f"{auth_settings.frontend_url}{name}/invite/{token}/{role_tok}/mixer=invite/" 172 | resp = { 173 | "message": "Invite Link Created successfully", 174 | "data": invite_link, 175 | "status": status.HTTP_200_OK, 176 | } 177 | return resp 178 | 179 | def revoke_org_link(self, org_slug: str): 180 | # check for org 181 | org = self.org_repo.get_org(org_slug) 182 | self.org_check(org) 183 | # revoke link 184 | org.revoke_link = True 185 | # org update 186 | self.org_repo.update_org(org) 187 | # orm call 188 | org_ = self.orm_call(org) 189 | 190 | resp = { 191 | "message": "Org revoked successfully", 192 | "data": org_, 193 | "status": status.HTTP_200_OK, 194 | } 195 | return resp 196 | 197 | # member org check 198 | def get_org_member_check(self, id: int, org_id: int): 199 | return self.org_member_repo.get_org_member(org_id, id) 200 | 201 | # raise Exception if not a member 202 | def org_member_check(self, org_member): 203 | if not org_member: 204 | raise HTTPException( 205 | detail="User is not a member of the workspace", 206 | status_code=status.HTTP_404_NOT_FOUND, 207 | ) 208 | 209 | # raise Exception if aleady a member 210 | def org_member_check_(self, org_member): 211 | if org_member: 212 | raise HTTPException( 213 | detail="User is a member of the workspace", 214 | status_code=status.HTTP_404_NOT_FOUND, 215 | ) 216 | 217 | def join_org( 218 | self, token: str, role_token: str, join_workspace: schemas.JoinOrg 219 | ) -> schemas.MessageOrgMembResp: 220 | token_data = retrieve_token(token) 221 | role_tok_data = retrieve_token(role_token) 222 | # if not token_data raise Exception 223 | if not token_data: 224 | raise HTTPException( 225 | detail="Token invalid", status_code=status.HTTP_409_CONFLICT 226 | ) 227 | # if not role_tok_data specify role to member 228 | if not role_tok_data: 229 | role_tok_data = RoleOptions.member 230 | 231 | # check if org exists. 232 | org_check = self.org_repo.get_org(token_data) 233 | 234 | if not org_check: 235 | raise HTTPException( 236 | detail="Org does not exist", status_code=status.HTTP_404_NOT_FOUND 237 | ) 238 | # check if org link is revoked 239 | if org_check.revoke_link is True: 240 | raise HTTPException( 241 | detail="Link for Invitation has been revoked", 242 | status_code=status.HTTP_400_BAD_REQUEST, 243 | ) 244 | # check if invited email belongs to a user 245 | user_check = user_repo(self.db).get_user(join_workspace.email) 246 | # raise Exception if no User 247 | if not user_check: 248 | raise HTTPException( 249 | detail="No account for this detail", 250 | status_code=status.HTTP_404_NOT_FOUND, 251 | ) 252 | 253 | # check if user is a member of the org 254 | org_mem_check = self.org_member_repo.get_org_member_by_user_id( 255 | org_check.id, user_check.id 256 | ) 257 | self.org_member_check_(org_mem_check) 258 | # data mapping 259 | org_member_data = { 260 | "member_id": user_check.id, 261 | "role": role_tok_data, 262 | "org_id": org_check.id, 263 | } 264 | # create org member 265 | org_member = self.org_member_repo.create_org_member(org_member_data) 266 | # orm call 267 | org_member_ = self.member_orm_call(org_member) 268 | resp = { 269 | "message": "User Joined Org", 270 | "data": org_member_, 271 | "status": status.HTTP_201_CREATED, 272 | } 273 | 274 | return resp 275 | 276 | def get_org_member(self, id: int, org_slug: str) -> schemas.MessageOrgMembResp: 277 | # get Org 278 | org = self.org_repo.get_org(org_slug) 279 | self.org_check(org) 280 | # check Org Member 281 | org_member_check = self.get_org_member_check(id, org.id) 282 | self.org_member_check(org_member_check) 283 | # orm call 284 | org_member = self.member_orm_call(org_member_check) 285 | resp = { 286 | "message": "Org Member Retrieved Successfully", 287 | "data": org_member, 288 | "status": status.HTTP_200_OK, 289 | } 290 | 291 | return resp 292 | 293 | def get_all_org_member(self, org_slug: str) -> schemas.MessageListOrgResp: 294 | # check org 295 | org = self.org_repo.get_org(org_slug) 296 | self.org_check(org) 297 | 298 | # check for members 299 | org_member_check = self.org_member_repo.get_org_members(org.id) 300 | self.org_member_check(org_member_check) 301 | 302 | # orm call 303 | org_member_ = [] 304 | for org_member in org_member_check: 305 | org_member_.append(self.member_orm_call(org_member)) 306 | 307 | resp = { 308 | "message": "Org Members retrieved successfully", 309 | "data": org_member_, 310 | "status": status.HTTP_200_OK, 311 | } 312 | return resp 313 | 314 | def update_org_member( 315 | self, 316 | org_slug: str, 317 | id: int, 318 | role_update: schemas.UpdateOrgMember, 319 | ): 320 | # org check 321 | org = self.org_repo.get_org(org_slug) 322 | self.org_check(org) 323 | # member check 324 | org_member = self.org_member_repo.get_org_member(org.id, id) 325 | self.org_member_check(org_member) 326 | # Update role 327 | org_member.role = role_update.role 328 | # update org member insntance 329 | org_member = self.org_member_repo.update_org_member(org_member) 330 | org_member_ = self.member_orm_call(org_member) 331 | resp = { 332 | "message": "Org Member Updated Successfully", 333 | "data": org_member_, 334 | "status": status.HTTP_200_OK, 335 | } 336 | 337 | return resp 338 | 339 | def delete_org_member(self, org_slug: str, user_id: int): 340 | # check for Org 341 | org = self.org_repo.get_org(org_slug) 342 | self.org_check(org) 343 | # checking for org memeber 344 | org_member = self.org_member_repo.get_org_member(org.id, user_id) 345 | self.org_member_check(org_member) 346 | # deleting the org memeber 347 | self.org_member_repo.delete_org_member(org_member) 348 | 349 | def leave_org(self, org_slug: str, user_id: int): 350 | # check for ORg 351 | org = self.org_repo.get_org(org_slug) 352 | self.org_check(org) 353 | # check for org_member innstance 354 | org_member = self.org_member_repo.get_org_member_by_user_id(org.id, user_id) 355 | self.org_member_check(org_member) 356 | # deleting the instance 357 | self.org_member_repo.delete_org_member(org_member) 358 | 359 | 360 | org_service = OrgService 361 | -------------------------------------------------------------------------------- /src/organization/pipes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philipokiokio/FastAPI_SAAS_Template/e1579bb23c7f3de8b84511db0db1b6399da443b4/src/organization/pipes/__init__.py -------------------------------------------------------------------------------- /src/organization/pipes/org_dep.py: -------------------------------------------------------------------------------- 1 | # framework imports 2 | from fastapi import Depends, HTTPException, status 3 | 4 | # application imports 5 | from src.auth.oauth import get_current_user 6 | from src.organization.org_repository import org_repo 7 | from src.permissions.org_permissions import org_perms 8 | from src.app.utils.db_utils import get_db 9 | from sqlalchemy.orm import Session 10 | 11 | 12 | # Allows a User to create more than 2 Organization if Premium user 13 | def premium_ulimited_orgs(current_user: dict = Depends(get_current_user),db:Session = Depends(get_db)): 14 | user_orgs = org_repo(db).get_orgs_created_by_user(user_id=current_user.id) 15 | if user_orgs: 16 | if current_user.is_premium is False: 17 | if len(user_orgs) >= 2: 18 | raise HTTPException( 19 | detail="Freemium Users can only create a Maximum of two Orgs", 20 | status_code=status.HTTP_400_BAD_REQUEST, 21 | ) 22 | return current_user 23 | 24 | 25 | # Admin Right check. 26 | def admin_rights_dep(org_slug: str, current_user: dict = Depends(get_current_user),db:Session = Depends(get_db)): 27 | org_perms(db).admin_right(current_user, org_slug) 28 | return current_user 29 | 30 | 31 | # Check logged in user is a member of an Organization. 32 | def member_dep(org_slug: str, current_user: dict = Depends(get_current_user),db:Session = Depends(get_db)): 33 | org_perms(db).org_member_check(current_user, org_slug) 34 | return current_user 35 | -------------------------------------------------------------------------------- /src/organization/schemas.py: -------------------------------------------------------------------------------- 1 | # python imports 2 | from typing import List, Optional, Union 3 | 4 | # 3rd party imports 5 | from pydantic import EmailStr 6 | 7 | # application imports 8 | from src.app.utils.schemas_utils import AbstractModel, ResponseModel, RoleOptions, User 9 | 10 | 11 | # Org Create DTO 12 | class OrgCreate(AbstractModel): 13 | name: str 14 | 15 | 16 | # Org Member 17 | class OrgMember(AbstractModel): 18 | role: str 19 | member: User 20 | 21 | 22 | # Org Response 23 | class OrgResponse(OrgCreate): 24 | id: int 25 | slug: str 26 | revoke_link: Optional[bool] 27 | creator: User 28 | members: Union[List[OrgMember], None] 29 | 30 | 31 | # Org Response DTO 32 | class MessageOrgResp(ResponseModel): 33 | data: OrgResponse 34 | 35 | 36 | # All Org Response DTO 37 | class MessageListOrgResp(ResponseModel): 38 | data: List[OrgResponse] 39 | 40 | 41 | # Org Update 42 | class OrgUpdate(AbstractModel): 43 | name: Optional[str] 44 | revoke_link: Optional[bool] 45 | 46 | 47 | # Update Role 48 | class UpdateRole(AbstractModel): 49 | role: RoleOptions 50 | 51 | 52 | # ORG ORG Resp 53 | class Org(AbstractModel): 54 | name: str 55 | slug: str 56 | 57 | 58 | # OrgMember Response DTO 59 | class OrgMemberResponse(AbstractModel): 60 | id: int 61 | org: Org 62 | user: User 63 | role: str 64 | 65 | 66 | # OrgMemberResponse DTO 67 | class MessageOrgMembResp(ResponseModel): 68 | data: OrgMemberResponse 69 | 70 | 71 | # List of OrgMembersResponse DTO 72 | class MessageListOrgMemResp(ResponseModel): 73 | data: List[OrgMemberResponse] 74 | 75 | 76 | # Join Org 77 | class JoinOrg(AbstractModel): 78 | email: EmailStr 79 | 80 | 81 | # Update OrgMemberRole 82 | class UpdateOrgMember(AbstractModel): 83 | role: RoleOptions 84 | 85 | 86 | # Invite Org Resp 87 | class InviteOrgResponse(ResponseModel): 88 | data: str 89 | -------------------------------------------------------------------------------- /src/permissions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philipokiokio/FastAPI_SAAS_Template/e1579bb23c7f3de8b84511db0db1b6399da443b4/src/permissions/__init__.py -------------------------------------------------------------------------------- /src/permissions/org_permissions.py: -------------------------------------------------------------------------------- 1 | # framework imports 2 | from fastapi import HTTPException, status 3 | 4 | # application imports 5 | from src.app.utils.schemas_utils import RoleOptions 6 | from src.auth.models import User 7 | from src.organization.org_repository import Organization, org_member_repo, org_repo 8 | 9 | 10 | class OrgPerms: 11 | def __init__(self, db) -> None: 12 | # intializing organization repos 13 | self.db =db 14 | self.repo = org_repo(self.db) 15 | self.member_repo = org_member_repo(self.db) 16 | 17 | # check if an Organization Exists 18 | def org_check(self, org_slug: str): 19 | org_ = self.repo.get_org(org_slug) 20 | if not org_: 21 | raise HTTPException( 22 | detail="No Organization with this slug", 23 | status_code=status.HTTP_404_NOT_FOUND, 24 | ) 25 | 26 | return org_ 27 | 28 | # checks if a user is a Memeber of an Organization 29 | def org_member_check(self, current_user: User, org_slug: str): 30 | org = self.org_check(org_slug) 31 | org_member = self.member_repo.get_org_member_by_user_id(org.id, current_user.id) 32 | if not org_member: 33 | raise HTTPException( 34 | detail="Logged in User is not a member of this Organization", 35 | status_code=status.HTTP_404_NOT_FOUND, 36 | ) 37 | return org_member 38 | 39 | # Checks if a user is an Admin 40 | def admin_right(self, current_user: User, org_slug: str): 41 | org_member = self.org_member_check(current_user, org_slug) 42 | if org_member.role != RoleOptions.admin.value: 43 | raise HTTPException( 44 | detail="Org Member is not Admin", status_code=status.HTTP_409_CONFLICT 45 | ) 46 | 47 | 48 | # instantiaion OrgPerms 49 | org_perms = OrgPerms 50 | -------------------------------------------------------------------------------- /src/templates/user/verification.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Simple Transactional Email 8 | 101 | 102 | 103 | 105 | 108 | 111 | 112 | 113 | 216 | 217 | 218 | 219 | 220 | 221 | -------------------------------------------------------------------------------- /src/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philipokiokio/FastAPI_SAAS_Template/e1579bb23c7f3de8b84511db0db1b6399da443b4/src/tests/__init__.py -------------------------------------------------------------------------------- /src/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | 4 | from src.app import main 5 | from src.app.config import test_status 6 | from src.app.database import Base, test_engine,TestFactory 7 | from src.app.utils.token import gen_token 8 | from src.auth.oauth import create_access_token, create_refresh_token 9 | from src.app.utils.db_utils import get_db 10 | 11 | # Test SQLAlchemy DBURL 12 | 13 | @pytest.fixture 14 | def session(): 15 | Base.metadata.drop_all(test_engine) 16 | Base.metadata.create_all(test_engine) 17 | 18 | 19 | 20 | 21 | 22 | 23 | db = TestFactory() 24 | try: 25 | 26 | yield db 27 | finally: 28 | db.close() 29 | 30 | 31 | @pytest.fixture() 32 | def client(session): 33 | 34 | # run our code beforewe run our test 35 | def get_test_db(): 36 | try: 37 | 38 | yield session 39 | finally: 40 | session.close() 41 | 42 | main.app.dependency_overrides[get_db] = get_test_db 43 | yield TestClient(main.app) 44 | 45 | 46 | 47 | 48 | 49 | user_data = { 50 | "email": "test@gmail.com", 51 | "password": "anotherday", 52 | "first_name": "Philip", 53 | "last_name": "thebackend", 54 | "is_verified": False, 55 | } 56 | 57 | 58 | first_user_data = { 59 | "email": "tester@gmail.com", 60 | "password": "anotherday", 61 | "first_name": "Philip", 62 | "last_name": "thebackend", 63 | "is_verified": True, 64 | } 65 | 66 | second_user_data = { 67 | "email": "test@mail.com", 68 | "password": "anotherday", 69 | "first_name": "Philip", 70 | "last_name": "thebackend", 71 | "is_verified": False, 72 | } 73 | 74 | 75 | @pytest.fixture 76 | def first_user(client): 77 | client: TestClient = client 78 | res = client.post("/api/v1/auth/register/", json=first_user_data) 79 | 80 | assert res.status_code == 201 81 | new_user = res.json()["data"] 82 | new_user["password"] = first_user_data["password"] 83 | return new_user 84 | 85 | 86 | @pytest.fixture 87 | def second_user(client): 88 | client: TestClient = client 89 | res = client.post("/api/v1/auth/register/", json=second_user_data) 90 | 91 | assert res.status_code == 201 92 | new_user = res.json()["data"] 93 | new_user["password"] = second_user_data["password"] 94 | return new_user 95 | 96 | 97 | @pytest.fixture 98 | def first_user_login(client, first_user): 99 | client: TestClient = client 100 | res = client.post( 101 | "/api/v1/auth/login/", 102 | data={"username": first_user["email"], "password": first_user["password"]}, 103 | ) 104 | assert res.status_code == 200 105 | return res.json().get("data") 106 | 107 | 108 | # Auth Clients for refresh and access token for the first user 109 | 110 | 111 | @pytest.fixture 112 | def first_user_access(first_user): 113 | access_token = create_access_token(first_user) 114 | return access_token 115 | 116 | 117 | @pytest.fixture 118 | def secnd_user_access(second_user): 119 | access_token = create_access_token(second_user) 120 | return access_token 121 | 122 | 123 | @pytest.fixture 124 | def first_user_refresh(first_user): 125 | data = {"email": first_user["email"]} 126 | refresh_token = create_refresh_token(data) 127 | return refresh_token 128 | 129 | 130 | @pytest.fixture 131 | def first_auth_client(client, first_user_access): 132 | client: TestClient = client 133 | client.headers = {**client.headers, "Authorization": f"Bearer {first_user_access}"} 134 | return client 135 | 136 | 137 | @pytest.fixture 138 | def secnd_auth_client(client, secnd_user_access): 139 | client: TestClient = client 140 | client.headers = {**client.headers, "Authorization": f"Bearer {secnd_user_access}"} 141 | return client 142 | 143 | 144 | first_org = {"name": "stripe"} 145 | second_org = {"name": "paystack"} 146 | 147 | 148 | @pytest.fixture 149 | def first_user_org_created(first_auth_client): 150 | client: TestClient = first_auth_client 151 | 152 | res = client.post("api/v1/org/create/", json=first_org) 153 | return res.json() 154 | 155 | 156 | @pytest.fixture 157 | def first_user_2nd_org_created(first_auth_client, first_user_org_created): 158 | client: TestClient = first_auth_client 159 | 160 | res = client.post("api/v1/org/create/", json=second_org) 161 | return res.json().get("data") 162 | 163 | 164 | @pytest.fixture 165 | def org_memb_2nd_join(first_user_2nd_org_created, client, secnd_user_access): 166 | client: TestClient = client 167 | 168 | token = gen_token(first_user_2nd_org_created["slug"]) 169 | role_token = gen_token("Member") 170 | res = client.post( 171 | "/api/v1/org/join/", 172 | params={"token": token, "role_token": role_token}, 173 | json={"email": second_user_data["email"]}, 174 | ) 175 | return res.json().get("data") 176 | -------------------------------------------------------------------------------- /src/tests/test_auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philipokiokio/FastAPI_SAAS_Template/e1579bb23c7f3de8b84511db0db1b6399da443b4/src/tests/test_auth/__init__.py -------------------------------------------------------------------------------- /src/tests/test_auth/test_auth.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from src.app.utils.token import auth_token 4 | from src.tests.conftest import ( 5 | TestClient, 6 | client, 7 | first_user_data, 8 | second_user_data, 9 | user_data, 10 | ) 11 | 12 | auth_route = "/api/v1/auth" 13 | 14 | 15 | def test_root(client): 16 | client: TestClient = client 17 | res = client.get("/") 18 | assert res.json().get("message") == "Welcome to FastAPI SAAS Template" 19 | assert res.status_code == 200 20 | 21 | 22 | @pytest.mark.asyncio 23 | async def test_registration(client): 24 | client: TestClient = client 25 | res = client.post(f"{auth_route}/register/", json=user_data) 26 | 27 | assert res.json().get("message") == "Registration Successful" 28 | 29 | assert res.json().get("data")["email"] == user_data["email"] 30 | assert res.status_code == 201 31 | 32 | 33 | def test_login(client, first_user): 34 | client: TestClient = client 35 | res = client.post( 36 | f"{auth_route}/login/", 37 | data={"username": first_user["email"], "password": first_user["password"]}, 38 | ) 39 | assert res.status_code == 200 40 | assert res.json().get("message") == "Login Successful" 41 | data = res.json().get("data") 42 | assert data["data"]["email"] == first_user_data["email"] 43 | 44 | 45 | def test_resend_verification(client, second_user): 46 | email_data = {"email": second_user_data["email"]} 47 | client: TestClient = client 48 | res = client.post(f"{auth_route}/resend-account-verification/", json=email_data) 49 | 50 | assert res.status_code == 200 51 | # assert res.json().get("message") == "Account Verification Mail sent successfully" 52 | 53 | 54 | def test_account_verification(client, second_user): 55 | client: TestClient = client 56 | token = auth_token(second_user_data["email"]) 57 | 58 | res = client.post(f"{auth_route}/account-verification/{token}/") 59 | 60 | assert res.status_code == 200 61 | assert res.json().get("status") == 200 62 | assert res.json().get("message") == "User Account is verified successfully" 63 | 64 | 65 | def test_fail_me(first_user, client): 66 | client: TestClient = client 67 | res = client.get(f"{auth_route}/me/") 68 | 69 | assert res.status_code == 401 70 | assert res.json().get("detail") == "Not authenticated" 71 | 72 | 73 | def test_me(first_auth_client): 74 | client: TestClient = first_auth_client 75 | 76 | res = client.get(f"{auth_route}/me/") 77 | 78 | assert res.status_code == 200 79 | assert res.json().get("message") == "Me Data" 80 | assert res.json().get("data")["email"] == first_user_data["email"] 81 | 82 | 83 | def test_update(first_auth_client): 84 | client: TestClient = first_auth_client 85 | 86 | res = client.patch( 87 | f"{auth_route}/update/", 88 | json={"first_name": "name_change", "last_name": "change_name"}, 89 | ) 90 | assert res.status_code == 200 91 | assert res.json().get("data")["first_name"] == "name_change" 92 | 93 | 94 | def test_delete(first_auth_client): 95 | client: TestClient = first_auth_client 96 | 97 | res = client.delete(f"{auth_route}/delete/") 98 | assert res.status_code == 204 99 | 100 | 101 | def test_auth_refresh(client, first_user_login): 102 | client: TestClient = client 103 | client.headers = { 104 | **client.headers, 105 | "Refresh-Tok": first_user_login["refresh_token"]["token"], 106 | } 107 | res = client.get("/api/v1/auth/refresh/") 108 | 109 | assert res.status_code == 200 110 | assert type(res.json().get("token")) == str 111 | assert res.json().get("message") == "New access token created successfully" 112 | 113 | 114 | def test_password_change(first_auth_client, first_user): 115 | client: TestClient = first_auth_client 116 | res = client.patch( 117 | f"{auth_route}/change-password/", 118 | json={"password": "new_password", "old_password": first_user["password"]}, 119 | ) 120 | assert res.status_code == 200 121 | assert res.json().get("message") == "Password changed successfully" 122 | 123 | 124 | def test_password_reset(client, first_user): 125 | client: TestClient = client 126 | 127 | res = client.post( 128 | f"{auth_route}/reset-password/", json={"email": first_user["email"]} 129 | ) 130 | 131 | assert res.status_code == 200 132 | # assert res.json().get("mail_status") == True 133 | # assert res.json().get("message") == "Reset Mail sent successfully" 134 | 135 | 136 | def test_password_reset_done(client, first_user): 137 | token = auth_token(first_user["email"]) 138 | client: TestClient = client 139 | 140 | res = client.post( 141 | f"{auth_route}/password-reset/complete/{token}/", 142 | json={"password": "changedone"}, 143 | ) 144 | 145 | assert res.status_code == 200 146 | assert res.json().get("message") == "User password set successfully" 147 | -------------------------------------------------------------------------------- /src/tests/test_orgs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philipokiokio/FastAPI_SAAS_Template/e1579bb23c7f3de8b84511db0db1b6399da443b4/src/tests/test_orgs/__init__.py -------------------------------------------------------------------------------- /src/tests/test_orgs/test_org.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from src.app.utils.token import gen_token 4 | from src.tests.conftest import TestClient, first_user_data, second_user_data 5 | 6 | org_route = "/api/v1/org" 7 | 8 | 9 | def test_org_creation(first_auth_client): 10 | client: TestClient = first_auth_client 11 | 12 | res = client.post(f"{org_route}/create/", json={"name": "stripe"}) 13 | assert res.status_code == 201 14 | assert res.json().get("message") == "Org Created Successfully" 15 | assert res.json().get("data")["name"] == "stripe" 16 | 17 | 18 | def test_org_premium( 19 | first_auth_client, first_user_org_created, first_user_2nd_org_created 20 | ): 21 | client: TestClient = first_auth_client 22 | 23 | res = client.post(f"{org_route}/create/", json={"name": "stripe"}) 24 | 25 | assert res.status_code == 400 26 | assert ( 27 | res.json().get("detail") 28 | == "Freemium Users can only create a Maximum of two Orgs" 29 | ) 30 | 31 | 32 | def test_orgs(first_auth_client, first_user_2nd_org_created, first_user_org_created): 33 | client: TestClient = first_auth_client 34 | 35 | res = client.get(f"{org_route}s/") 36 | 37 | assert res.status_code == 200 38 | assert type(res.json().get("data")) == list 39 | assert res.json().get("message") == "User Orgs retrieved successfully" 40 | 41 | 42 | def test_get_org(first_auth_client, first_user_org_created): 43 | client: TestClient = first_auth_client 44 | slug = first_user_org_created["data"]["slug"] 45 | res = client.get(f"{org_route}/{slug}") 46 | 47 | assert res.status_code == 200 48 | assert res.json().get("message") == "Org Returned" 49 | assert res.json().get("data")["slug"] == slug 50 | pass 51 | 52 | 53 | def test_update_org(first_auth_client, first_user_org_created): 54 | client: TestClient = first_auth_client 55 | 56 | slug = first_user_org_created["data"]["slug"] 57 | res = client.patch(f"{org_route}/{slug}/update/", json={"revoke_link": True}) 58 | 59 | assert res.status_code == 200 60 | assert type(res.json().get("data")) == dict 61 | 62 | 63 | def test_delete_org(first_auth_client, first_user_org_created): 64 | client: TestClient = first_auth_client 65 | 66 | slug = first_user_org_created["data"]["slug"] 67 | 68 | res = client.delete(f"{org_route}/{slug}/delete/") 69 | 70 | assert res.status_code == 204 71 | 72 | 73 | def test_gen_invite_link(first_auth_client, first_user_org_created): 74 | client: TestClient = first_auth_client 75 | slug = first_user_org_created["data"]["slug"] 76 | res = client.post(f"{org_route}/{slug}/invite-link/gen/", json={"role": "Member"}) 77 | 78 | assert res.status_code == 200 79 | assert type(res.json().get("data")) == str 80 | assert res.json().get("message") == "Invite Link Created successfully" 81 | 82 | 83 | def test_revoke_invite_link(first_auth_client, first_user_org_created): 84 | client: TestClient = first_auth_client 85 | slug = first_user_org_created["data"]["slug"] 86 | res = client.post(f"{org_route}/{slug}/revoke-link/") 87 | 88 | assert res.status_code == 200 89 | assert res.json().get("message") == "Org revoked successfully" 90 | assert res.json().get("data")["slug"] == slug 91 | 92 | 93 | @pytest.fixture 94 | def test_org_memb_join(client, first_user_2nd_org_created, secnd_user_access): 95 | client: TestClient = client 96 | token = gen_token(first_user_2nd_org_created["slug"]) 97 | role_token = gen_token("Member") 98 | res = client.post( 99 | f"{org_route}/join/", 100 | params={"token": token, "role_token": role_token}, 101 | json={"email": second_user_data["email"]}, 102 | ) 103 | 104 | assert res.json().get("message") == "User Joined Org" 105 | assert res.status_code == 200 106 | assert res.json().get("data")["user"]["email"] == second_user_data["email"] 107 | return res.json().get("data") 108 | 109 | 110 | def test_get_all_org_members( 111 | first_auth_client, first_user_2nd_org_created, org_memb_2nd_join 112 | ): 113 | client: TestClient = first_auth_client 114 | org_slug = first_user_2nd_org_created["slug"] 115 | 116 | res = client.get(f"{org_route}/{org_slug}/members/") 117 | 118 | assert res.status_code == 200 119 | 120 | assert res.json().get("message") == "Org Members retrieved successfully" 121 | 122 | for data in res.json().get("data"): 123 | if data["user"]["email"] == first_user_data["email"]: 124 | assert data["role"] == "Admin" 125 | 126 | elif data["user"]["email"] == second_user_data["email"]: 127 | assert data["role"] == "Member" 128 | 129 | 130 | # def test_get_org_member( 131 | # secnd_auth_client, first_user_2nd_org_created, test_org_memb_join, secnd_user_access 132 | # ): 133 | # client: TestClient = secnd_auth_client 134 | # slug = first_user_2nd_org_created["slug"] 135 | # memb_id = test_org_memb_join["id"] 136 | # res = client.get(f"{org_route}/{slug}/member/{memb_id}/") 137 | 138 | # assert res.status_code == 200 139 | # assert res.json().get("message") == "Org Member Retrieved Successfully" 140 | # assert res.json().get("data")["id"] == memb_id 141 | 142 | 143 | def test_delete_org_memb( 144 | first_auth_client, first_user_2nd_org_created, test_org_memb_join, secnd_user_access 145 | ): 146 | client: TestClient = first_auth_client 147 | slug = first_user_2nd_org_created["slug"] 148 | memb_id = test_org_memb_join["id"] 149 | res = client.delete(f"{org_route}/{slug}/member/{memb_id}/delete/") 150 | 151 | assert res.status_code == 204 152 | 153 | 154 | # def test_leave_org_memb( 155 | # first_auth_client, 156 | # secnd_auth_client, 157 | # first_user_2nd_org_created, 158 | # test_org_memb_join, 159 | # ): 160 | # client: TestClient = secnd_auth_client 161 | # slug = first_user_2nd_org_created["slug"] 162 | # res = client.get(f"{org_route}/{slug}/member/leave/") 163 | 164 | # assert res.status_code == 204 165 | --------------------------------------------------------------------------------