diff --git a/.env.sample b/.env.sample index 300e463bd..7c09ba660 100644 --- a/.env.sample +++ b/.env.sample @@ -1,12 +1,12 @@ PYTHON_ENV=dev -DB_TYPE=mysql +DB_TYPE=postgresql DB_NAME=dbname DB_USER=user DB_PASSWORD=password DB_HOST=127.0.0.1 -DB_PORT=3306 -MYSQL_DRIVER=pymysql -DB_URL="mysql+pymysql://user:password@127.0.0.1:3306/dbname" +DB_PORT=5432 +MYSQL_DRIVER=pymysql +DB_URL="postgresql://user:password@127.0.0.1:5432/dbname" SECRET_KEY = "" ALGORITHM = HS256 ACCESS_TOKEN_EXPIRE_MINUTES = 10 diff --git a/.github/workflows/cd.dev.yml b/.github/workflows/cd.dev.yml new file mode 100644 index 000000000..74bd6a44e --- /dev/null +++ b/.github/workflows/cd.dev.yml @@ -0,0 +1,36 @@ +name: Dev Branch Deployment + +on: + workflow_run: + workflows: ["CI"] + types: + - completed + branches: [dev] + +jobs: + on-success: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Use SSH Action + uses: appleboy/ssh-action@v0.1.8 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + password: ${{ secrets.PASSWORD }} + script: | + cd python/dev_source_code/ + git pull origin dev + source .venv/bin/activate + pip install -r requirements.txt + + + + on-failure: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'failure' }} + steps: + - run: echo 'The triggering workflow failed' diff --git a/.github/workflows/cd.prod.yml b/.github/workflows/cd.prod.yml new file mode 100644 index 000000000..21a26eba3 --- /dev/null +++ b/.github/workflows/cd.prod.yml @@ -0,0 +1,36 @@ +name: Dev Branch Deployment + +on: + workflow_run: + workflows: ["CI"] + types: + - completed + branches: [main] + +jobs: + on-success: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Use SSH Action + uses: appleboy/ssh-action@v0.1.8 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + password: ${{ secrets.PASSWORD }} + script: | + cd python/prod_source_code/ + git pull origin main + source .venv/bin/activate + pip install -r requirements.txt + + + + on-failure: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'failure' }} + steps: + - run: echo 'The triggering workflow failed' diff --git a/.github/workflows/cd.staging.yml b/.github/workflows/cd.staging.yml new file mode 100644 index 000000000..01653a2a7 --- /dev/null +++ b/.github/workflows/cd.staging.yml @@ -0,0 +1,36 @@ +name: Dev Branch Deployment + +on: + workflow_run: + workflows: ["CI"] + types: + - completed + branches: [staging] + +jobs: + on-success: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Use SSH Action + uses: appleboy/ssh-action@v0.1.8 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + password: ${{ secrets.PASSWORD }} + script: | + cd python/staging_source_code/ + git pull origin staging + source .venv/bin/activate + pip install -r requirements.txt + + + + on-failure: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'failure' }} + steps: + - run: echo 'The triggering workflow failed' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..c36e7b435 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + branches: [main, staging, dev] + pull_request: + branches: [main, staging, dev] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:latest + env: + POSTGRES_USER: + POSTGRES_PASSWORD: + POSTGRES_DB: + ports: + - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.9" + virtual-environment: venv + + - name: Install dependencies + run: | + pip install -r requirements.txt + + # - name: Copy env file + # run: cp .env.sample .env + + # - name: Copy env file for main + # if: github.ref == 'refs/heads/main' + # run: cp .env.production .env + + # - name: Copy env file for staging + # if: github.ref == 'refs/heads/staging' + # run: cp .env.staging .env + + # - name: Run migrations + # run: | + # activate + # alembic upgrade head + + # - name: Run tests + # run: | + # activate + # pytest diff --git a/README.md b/README.md index 27935829a..d7e99e94c 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,63 @@ FastAPI boilerplate python main.py ``` +## **DATABASE TEST SETUP** + +To set up the database, follow the following steps: + +**Cloning** +- clone the repository using `git clone https://github.com/hngprojects/hng_boilerplate_python_fastapi_web` +- `cd` into the directory hng_boilerplate_python_fastapi_web +- switch branch using `git checkout Muritadhor` + +**Environment Setup** +- run `pip install -r requrements.txt` to install dependencies +- create a `.env` file in the root directory and copy the content of `.env.sample` and update it accordingly + +**Create your local database** +```bash +sudo -u root psql +``` +```sql +CREATE USER user WITH PASSWORD 'password'; +CREATE DATABASE hng_fast_api; +GRANT ALL PRIVILEGES ON DATABASE hng_fast_api TO user; +``` + +**Migrate the database** +```bash +alembic revision --autogenerate -m "Initial migrate" +alembic upgrade head +``` + +**create dummy data** +```bash +python3 db_test.py +``` +This should run without any errors + +**Using the database in your route files:** + +make sure to add the following to your file + +```python +from api.db.database import create_database, get_db +from api.v1.models.user import User +from api.v1.models.org import Organization +from api.v1.models.profile import Profile +from api.v1.models.product import Product + +create_database() +db = next(get_db()) +``` +Then use the db for your queries + +example +```python +db.add(User(email="test@mail", username="testuser", password="testpass", first_name="John", last_name="Doe")) +``` + + +**Adding tables and columns to models** + +After creating new tables, or adding new models. Make sure to run alembic revision --autogenerate -m "Migration messge" diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 000000000..484de57fd --- /dev/null +++ b/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py index b78cb58bd..0caf842ca 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,16 +1,13 @@ - -import os -from alembic import context -from decouple import config from logging.config import fileConfig from sqlalchemy import engine_from_config from sqlalchemy import pool -from api.db.database import Base -from api.v1.models.auth import User, BlackListToken - -#from db.database import DATABASE_URL -DATABASE_URL=config('DB_URL') - +from alembic import context +from decouple import config as decouple_config +from api.v1.models.user import User +from api.v1.models.org import Organization +from api.v1.models.profile import Profile +from api.v1.models.product import Product +from api.v1.models.base import Base # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -21,6 +18,10 @@ if config.config_file_name is not None: fileConfig(config.config_file_name) +database_url = decouple_config('DATABASE_URL') + +# Set the SQLAlchemy URL dynamically +config.set_main_option('sqlalchemy.url', database_url) # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel @@ -45,7 +46,7 @@ def run_migrations_offline() -> None: script output. """ - url = config.get_main_option("sqlalchemy.url", DATABASE_URL) + url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, @@ -64,12 +65,8 @@ def run_migrations_online() -> None: and associate a connection with the context. """ - - alembic_config = config.get_section(config.config_ini_section) - alembic_config['sqlalchemy.url'] = DATABASE_URL - connectable = engine_from_config( - alembic_config, + config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", poolclass=pool.NullPool, ) diff --git a/alembic/versions/5a2dc9cc9735_initial_migration.py b/alembic/versions/5a2dc9cc9735_initial_migration.py new file mode 100644 index 000000000..37de63911 --- /dev/null +++ b/alembic/versions/5a2dc9cc9735_initial_migration.py @@ -0,0 +1,81 @@ +"""initial migration + +Revision ID: 5a2dc9cc9735 +Revises: +Create Date: 2024-07-19 00:08:59.055052 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5a2dc9cc9735' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('organizations', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('products', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('price', sa.Numeric(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('users', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('username', sa.String(length=50), nullable=False), + sa.Column('email', sa.String(length=100), nullable=False), + sa.Column('password', sa.String(length=255), nullable=False), + sa.Column('first_name', sa.String(length=50), nullable=True), + sa.Column('last_name', sa.String(length=50), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('username') + ) + op.create_table('profiles', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('bio', sa.Text(), nullable=True), + sa.Column('phone_number', sa.String(length=50), nullable=True), + sa.Column('avatar_url', sa.String(length=100), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('user_organization', + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('organization_id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('user_id', 'organization_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('user_organization') + op.drop_table('profiles') + op.drop_table('users') + op.drop_table('products') + op.drop_table('organizations') + # ### end Alembic commands ### diff --git a/alembic/versions/9c01926f167f_updated_profile_user_columns.py b/alembic/versions/9c01926f167f_updated_profile_user_columns.py new file mode 100644 index 000000000..970675d5f --- /dev/null +++ b/alembic/versions/9c01926f167f_updated_profile_user_columns.py @@ -0,0 +1,30 @@ +"""updated profile user columns + +Revision ID: 9c01926f167f +Revises: e36c5525f3b6 +Create Date: 2024-07-19 06:56:07.545775 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9c01926f167f' +down_revision: Union[str, None] = 'e36c5525f3b6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/ba7a518767c3_initial_migration.py b/alembic/versions/ba7a518767c3_initial_migration.py new file mode 100644 index 000000000..25812cb5f --- /dev/null +++ b/alembic/versions/ba7a518767c3_initial_migration.py @@ -0,0 +1,30 @@ +"""initial migration + +Revision ID: ba7a518767c3 +Revises: 5a2dc9cc9735 +Create Date: 2024-07-19 00:15:15.551494 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ba7a518767c3' +down_revision: Union[str, None] = '5a2dc9cc9735' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/e36c5525f3b6_updated_columns.py b/alembic/versions/e36c5525f3b6_updated_columns.py new file mode 100644 index 000000000..b8d29399b --- /dev/null +++ b/alembic/versions/e36c5525f3b6_updated_columns.py @@ -0,0 +1,96 @@ +"""updated columns + +Revision ID: e36c5525f3b6 +Revises: ba7a518767c3 +Create Date: 2024-07-19 06:49:15.962971 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'e36c5525f3b6' +down_revision: Union[str, None] = 'ba7a518767c3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('organizations', 'created_at', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True, + existing_server_default=sa.text('now()')) + op.alter_column('organizations', 'updated_at', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True, + existing_server_default=sa.text('now()')) + op.add_column('products', sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True)) + op.alter_column('products', 'created_at', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=False) + op.alter_column('profiles', 'created_at', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + op.alter_column('profiles', 'updated_at', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + op.create_unique_constraint(None, 'profiles', ['user_id']) + op.alter_column('users', 'created_at', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True, + existing_server_default=sa.text('now()')) + op.alter_column('users', 'updated_at', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True, + existing_server_default=sa.text('now()')) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'updated_at', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=True, + existing_server_default=sa.text('now()')) + op.alter_column('users', 'created_at', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=True, + existing_server_default=sa.text('now()')) + op.drop_constraint(None, 'profiles', type_='unique') + op.alter_column('profiles', 'updated_at', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=True) + op.alter_column('profiles', 'created_at', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=True) + op.alter_column('products', 'created_at', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=False) + op.drop_column('products', 'updated_at') + op.alter_column('organizations', 'updated_at', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=True, + existing_server_default=sa.text('now()')) + op.alter_column('organizations', 'created_at', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=True, + existing_server_default=sa.text('now()')) + # ### end Alembic commands ### diff --git a/api/db/database.py b/api/db/database.py index 472829eca..acb90c6f7 100644 --- a/api/db/database.py +++ b/api/db/database.py @@ -1,19 +1,23 @@ -# database.py +#!/usr/bin/env python3 +""" The database module +""" from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from decouple import config +from contextlib import contextmanager +from api.v1.models.base import Base def get_db_engine(): - DB_TYPE = config("DB_TYPE") - DB_NAME = config("DB_NAME") - DB_USER = config("DB_USER") - DB_PASSWORD = config("DB_PASSWORD") - DB_HOST = config("DB_HOST") - DB_PORT = config("DB_PORT") - MYSQL_DRIVER = config("MYSQL_DRIVER") + DB_TYPE = config("DB_TYPE", "postgresql") + DB_NAME = config("DB_NAME", "hng_fast_api") + DB_USER = config("DB_USER", "") + DB_PASSWORD = config("DB_PASSWORD", "") + DB_HOST = config("DB_HOST", "localhost") + DB_PORT = config("DB_PORT", "5432") + MYSQL_DRIVER = config("MYSQL_DRIVER", "") DATABASE_URL = "" if DB_TYPE == "mysql": @@ -35,13 +39,10 @@ def get_db_engine(): SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=db_engine) -Base = declarative_base() - def create_database(): return Base.metadata.create_all(bind=db_engine) - def get_db(): db = SessionLocal() try: diff --git a/api/v1/models/base.py b/api/v1/models/base.py new file mode 100644 index 000000000..92c0327ee --- /dev/null +++ b/api/v1/models/base.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +""" Base +""" +from sqlalchemy import ( + Column, + Integer, + ForeignKey, + Table + ) +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship +from datetime import datetime +from sqlalchemy.dialects.postgresql import UUID + + +Base = declarative_base() + +user_organization_association = Table('user_organization', Base.metadata, + Column('user_id', UUID(as_uuid=True), ForeignKey('users.id'), primary_key=True), + Column('organization_id', UUID(as_uuid=True), ForeignKey('organizations.id'), primary_key=True) + ) diff --git a/api/v1/models/org.py b/api/v1/models/org.py new file mode 100644 index 000000000..d8fea3d89 --- /dev/null +++ b/api/v1/models/org.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +""" The Organization model +""" +from sqlalchemy import ( + Column, + Integer, + String, + Text, + Date, + ForeignKey, + Numeric, + DateTime, + func, + ) +from sqlalchemy.orm import relationship +from datetime import datetime +from api.v1.models.base import Base, user_organization_association +from sqlalchemy.dialects.postgresql import UUID +from uuid_extensions import uuid7 + + +class Organization(Base): + __tablename__ = 'organizations' + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid7) + name = Column(String(50), unique=True, nullable=False) + description = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + users = relationship( + "User", + secondary=user_organization_association, + back_populates="organizations" + ) + + def __str__(self): + return self.name diff --git a/api/v1/models/product.py b/api/v1/models/product.py new file mode 100644 index 000000000..9692b931f --- /dev/null +++ b/api/v1/models/product.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +""" The Product model +""" +from sqlalchemy import ( + Column, + Integer, + String, + Text, + Numeric, + DateTime, + func, + ) +from datetime import datetime +from api.v1.models.base import Base +from sqlalchemy.dialects.postgresql import UUID +from uuid_extensions import uuid7 + + +class Product(Base): + __tablename__ = 'products' + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid7) + name = Column(String, nullable=False) + description = Column(Text, nullable=True) + price = Column(Numeric, nullable=False) + created_at = Column(DateTime(timezone=True), nullable=False, default=func.now()) + updated_at = Column(DateTime(timezone=True), default=func.now(), onupdate=func.now()) diff --git a/api/v1/models/profile.py b/api/v1/models/profile.py new file mode 100644 index 000000000..6aa3a8b63 --- /dev/null +++ b/api/v1/models/profile.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +""" The Profile model +""" +from sqlalchemy import ( + Column, + Integer, + String, + Text, + Date, + ForeignKey, + DateTime, + func, + ) +from sqlalchemy.orm import relationship +from sqlalchemy.dialects.postgresql import UUID +from datetime import datetime +from api.v1.models.base import Base +from uuid_extensions import uuid7 + + +class Profile(Base): + __tablename__ = 'profiles' + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid7) + user_id = Column(UUID(as_uuid=True), ForeignKey('users.id'), unique=True, nullable=False) + bio = Column(Text, nullable=True) + phone_number = Column(String(50), nullable=True) + avatar_url = Column(String(100), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + user = relationship("User", back_populates="profile") diff --git a/api/v1/models/user.py b/api/v1/models/user.py new file mode 100644 index 000000000..e92791640 --- /dev/null +++ b/api/v1/models/user.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +""" User data model +""" +from sqlalchemy import ( + create_engine, + Column, + Integer, + String, + Text, + Date, + ForeignKey, + Numeric, + DateTime, + func, + Table + ) +from sqlalchemy.orm import relationship +from datetime import datetime +from api.v1.models.base import Base, user_organization_association +import bcrypt +from uuid_extensions import uuid7 +from sqlalchemy.dialects.postgresql import UUID + + +def hash_password(password: str) -> bytes: + """ Hashes the user password for security + """ + salt = bcrypt.gensalt() + + hash_pw = bcrypt.hashpw(password.encode(), salt) + return hash_pw + +class User(Base): + __tablename__ = 'users' + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid7) + username = Column(String(50), unique=True, nullable=False) + email = Column(String(100), unique=True, nullable=False) + password = Column(String(255), nullable=False) + first_name = Column(String(50)) + last_name = Column(String(50)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + profile = relationship("Profile", uselist=False, back_populates="user") + organizations = relationship( + "Organization", + secondary=user_organization_association, + back_populates="users" + ) + + def __init__(self, **kwargs): + """ Initializes a user instance + """ + keys = ['username', 'email', 'password', 'first_name', 'last_name'] + for key, value in kwargs.items(): + if key in keys: + if key == 'password': + value = hash_password(value) + setattr(self, key, value) + + def __str__(self): + return self.email + diff --git a/database.db b/database.db new file mode 100644 index 000000000..13789cfbb Binary files /dev/null and b/database.db differ diff --git a/db_test.py b/db_test.py new file mode 100644 index 000000000..ea1174249 --- /dev/null +++ b/db_test.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +""" Populates the database with seed data +""" +from api.db.database import create_database, get_db +from api.v1.models.user import User +from api.v1.models.org import Organization +from api.v1.models.profile import Profile +from api.v1.models.product import Product + +create_database() +db = next(get_db()) + +user_1 = User(email="test@mail", username="testuser", password="testpass", first_name="John", last_name="Doe") +user_2 = User(email="test1@mail", username="testuser1", password="testpass1", first_name="Jane", last_name="Boyle") +user_3 = User(email="test2@mail", username="testuser2", password="testpass2", first_name="Bob", last_name="Dwayne") + +db.add_all([user_1, user_2, user_3]) + +org_1 = Organization(name="Python Org", description="An organization for python develoers") +org_2 = Organization(name="Django Org", description="An organization of django devs") +org_3 = Organization(name="FastAPI Devs", description="An organization of Fast API devs") + +db.add_all([org_1, org_2, org_3]) + +org_1.users.extend([user_1, user_2, user_3]) +org_2.users.extend([user_1, user_3]) +org_3.users.extend([user_2, user_1]) + +product_1 = Product(name="bed", price=400000) +product_2 = Product(name="shoe", price=150000) + +profile_1 = Profile(bio='My name is John Doe', phone_number='09022112233') +user_1.profile = profile_1 + +db.add_all([product_1, product_2]) +db.commit() +users = db.query(Organization).first().users +for user in users: + print(user.password) +print(profile_1.user_id) +print(profile_1.user) + diff --git a/requirements.txt b/requirements.txt index 853b6833e..3b4bb4a4f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,5 @@ python-dotenv bcrypt passlib pymysql +uuid7 +psycopg2-binary \ No newline at end of file