├── .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 |
116 |
118 |
119 |
120 |
123 |
124 |
125 |
126 |
129 |
132 |
133 |
135 |
137 | Hi {{first_name}},
138 |
140 | Sometimes you just want to send a simple HTML email with a simple design
141 | and clear call to action. This is it.
142 |
146 |
147 |
148 |
151 |
165 | |
166 |
167 |
168 |
169 |
171 | This is a really simple email template. Its sole purpose is to get the
172 | recipient to click the button with no distractions.
173 |
175 | Good luck! Hope it works.
176 | |
177 |
178 |
179 | |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
212 |
213 |
214 |
215 | |
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 |
--------------------------------------------------------------------------------