diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 000000000..ae1dc8be9 --- /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 = sqlite:///./database.db + + +[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..b98bf4f22 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,16 +1,12 @@ - -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 api.v1.models.user import User +from api.v1.models.org import Organisation +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. @@ -45,7 +41,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 +60,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/9670771a11f9_initial_migrations.py b/alembic/versions/9670771a11f9_initial_migrations.py new file mode 100644 index 000000000..644c854c6 --- /dev/null +++ b/alembic/versions/9670771a11f9_initial_migrations.py @@ -0,0 +1,81 @@ +"""Initial migrations + +Revision ID: 9670771a11f9 +Revises: +Create Date: 2024-07-18 20:08:23.466008 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9670771a11f9' +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('organisations', + sa.Column('id', sa.Integer(), autoincrement=True, 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('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('products', + sa.Column('id', sa.Integer(), autoincrement=True, 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.Integer(), autoincrement=True, 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('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('username') + ) + op.create_table('profiles', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), 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_organisation', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('organisation_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['organisation_id'], ['organisations.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('user_id', 'organisation_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('user_organisation') + op.drop_table('profiles') + op.drop_table('users') + op.drop_table('products') + op.drop_table('organisations') + # ### end Alembic commands ### diff --git a/api/db/database.py b/api/db/database.py index 472829eca..d53709632 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", "") + 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", "") 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..cf75a1544 --- /dev/null +++ b/api/v1/models/base.py @@ -0,0 +1,20 @@ +#!/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 + + +Base = declarative_base() + +user_organisation_association = Table('user_organisation', Base.metadata, + Column('user_id', Integer, ForeignKey('users.id'), primary_key=True), + Column('organisation_id', Integer, ForeignKey('organisations.id'), primary_key=True) + ) diff --git a/api/v1/models/org.py b/api/v1/models/org.py new file mode 100644 index 000000000..1d3554f75 --- /dev/null +++ b/api/v1/models/org.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +""" The Organisation 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_organisation_association + + +class Organisation(Base): + __tablename__ = 'organisations' + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(50), unique=True, nullable=False) + description = Column(Text, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + users = relationship( + "User", + secondary=user_organisation_association, + back_populates="organisations" + ) + + 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..3b7e8509e --- /dev/null +++ b/api/v1/models/product.py @@ -0,0 +1,24 @@ +#!/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 + + +class Product(Base): + __tablename__ = 'products' + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String, nullable=False) + description = Column(Text, nullable=True) + price = Column(Numeric, nullable=False) + created_at = Column(DateTime, nullable=False, default=func.now()) diff --git a/api/v1/models/profile.py b/api/v1/models/profile.py new file mode 100644 index 000000000..ed9bb94ad --- /dev/null +++ b/api/v1/models/profile.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +""" The Profile model +""" +from sqlalchemy import ( + Column, + Integer, + String, + Text, + Date, + ForeignKey, + DateTime, + func, + ) +from sqlalchemy.orm import relationship +from datetime import datetime +from api.v1.models.base import Base + + +class Profile(Base): + __tablename__ = 'profiles' + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey('users.id'), 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, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + #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..81d45c080 --- /dev/null +++ b/api/v1/models/user.py @@ -0,0 +1,61 @@ +#!/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_organisation_association +import bcrypt + +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(Integer, primary_key=True, autoincrement=True) + 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, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + profile = relationship("Profile", uselist=False) + organisations = relationship( + "Organisation", + secondary=user_organisation_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..dc5a11b6a --- /dev/null +++ b/db_test.py @@ -0,0 +1,41 @@ +#!/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 Organisation +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 = Organisation(name="Python Org", description="An organisation for python develoers") +org_2 = Organisation(name="Django Org", description="An organisation of django devs") +org_3 = Organisation(name="FastAPI Devs", description="An organisation 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(Organisation).first().users +for user in users: + print(user.password) +print(profile_1.user_id) +