diff --git a/.dockerignore b/.dockerignore index 095a53107..46b5b2859 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,9 @@ -venv -_*pycache*_ -qr_codes -*.db -*.sqlite -.vscode -.env -.coverage - +venv +_*pycache*_ +qr_codes +*.db +*.sqlite +.vscode +.env +.coverage + diff --git a/.env.sample b/.env.sample index 15f6ab343..99c9b7f33 100644 --- a/.env.sample +++ b/.env.sample @@ -1,4 +1,4 @@ -smtp_server=sandbox.smtp.mailtrap.io -smtp_port=2525 -smtp_username= +smtp_server=sandbox.smtp.mailtrap.io +smtp_port=2525 +smtp_username= smtp_password= \ No newline at end of file diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 1d6d27bb0..94b66b583 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -1,85 +1,85 @@ -name: CI/CD Pipeline - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: [3.10.12] # Define Python versions here - services: - postgres: - image: postgres:latest - env: - POSTGRES_USER: user - POSTGRES_PASSWORD: password - POSTGRES_DB: myappdb - ports: - - 5432:5432 - options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 - steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Cache Python packages - uses: actions/cache@v4 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run tests with Pytest - env: - DATABASE_URL: postgresql+asyncpg://user:password@localhost:5432/myappdb # Configure the DATABASE_URL environment variable for tests - run: pytest - - build-and-push-docker: - needs: test - runs-on: ubuntu-latest - environment: production - steps: - - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to DockerHub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - push: true - tags: woffee/wis_club_api:${{ github.sha }} # Uses the Git SHA for tagging - platforms: linux/amd64,linux/arm64 # Multi-platform support - cache-from: type=registry,ref=woffee/wis_club_api:cache - cache-to: type=inline,mode=max - - - name: Scan the Docker image - uses: aquasecurity/trivy-action@master - with: - image-ref: 'woffee/wis_club_api:${{ github.sha }}' - format: 'table' - exit-code: '1' # Fail the job if vulnerabilities are found - ignore-unfixed: true - severity: 'CRITICAL,HIGH' +name: CI/CD Pipeline + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.10.12] # Define Python versions here + services: + postgres: + image: postgres:latest + env: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: myappdb + ports: + - 5432:5432 + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache Python packages + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests with Pytest + env: + DATABASE_URL: postgresql+asyncpg://user:password@localhost:5432/myappdb # Configure the DATABASE_URL environment variable for tests + run: pytest + + build-and-push-docker: + needs: test + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + push: true + tags: woffee/wis_club_api:${{ github.sha }} # Uses the Git SHA for tagging + platforms: linux/amd64,linux/arm64 # Multi-platform support + cache-from: type=registry,ref=woffee/wis_club_api:cache + cache-to: type=inline,mode=max + + - name: Scan the Docker image + uses: aquasecurity/trivy-action@master + with: + image-ref: 'woffee/wis_club_api:${{ github.sha }}' + format: 'table' + exit-code: '1' # Fail the job if vulnerabilities are found + ignore-unfixed: true + severity: 'CRITICAL,HIGH' diff --git a/.gitignore b/.gitignore index 8b8bf1ba8..86f7d59a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,9 @@ -venv -_*pycache*_ -qr_codes -*.db -*.sqlite -.vscode -.env -.coverage +venv +_*pycache*_ +qr_codes +*.db +*.sqlite +.vscode +.env +.coverage .DS_Store \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index a26347203..c896258da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,42 +1,42 @@ -# Use an official lightweight Python image. -# 3.12-slim variant is chosen for a balance between size and utility. -FROM python:3.12-slim-bullseye as base - -# Set environment variables to configure Python and pip. -# Prevents Python from buffering stdout and stderr, enables the fault handler, disables pip cache, -# sets default pip timeout, and suppresses pip version check messages. -ENV PYTHONUNBUFFERED=1 \ - PYTHONFAULTHANDLER=1 \ - PIP_NO_CACHE_DIR=true \ - PIP_DEFAULT_TIMEOUT=100 \ - PIP_DISABLE_PIP_VERSION_CHECK=on \ - QR_CODE_DIR=/myapp/qr_codes - -# Set the working directory inside the container -WORKDIR /myapp - -# Install system dependencies -RUN apt-get update \ - && apt-get install -y --no-install-recommends gcc libpq-dev \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -# Copy only the requirements, to cache them in Docker layer -COPY ./requirements.txt /myapp/requirements.txt - -# Upgrade pip and install Python dependencies from requirements file -RUN pip install --upgrade pip \ - && pip install -r requirements.txt - -# Add a non-root user and switch to it -RUN useradd -m myuser -USER myuser - -# Copy the rest of your application's code with appropriate ownership -COPY --chown=myuser:myuser . /myapp - -# Inform Docker that the container listens on the specified port at runtime. -EXPOSE 8000 - -# Use ENTRYPOINT to specify the executable when the container starts. -# ENTRYPOINT ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +# Use an official lightweight Python image. +# 3.12-slim variant is chosen for a balance between size and utility. +FROM python:3.12-slim-bullseye as base + +# Set environment variables to configure Python and pip. +# Prevents Python from buffering stdout and stderr, enables the fault handler, disables pip cache, +# sets default pip timeout, and suppresses pip version check messages. +ENV PYTHONUNBUFFERED=1 \ + PYTHONFAULTHANDLER=1 \ + PIP_NO_CACHE_DIR=true \ + PIP_DEFAULT_TIMEOUT=100 \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + QR_CODE_DIR=/myapp/qr_codes + +# Set the working directory inside the container +WORKDIR /myapp + +# Install system dependencies +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc libpq-dev \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Copy only the requirements, to cache them in Docker layer +COPY ./requirements.txt /myapp/requirements.txt + +# Upgrade pip and install Python dependencies from requirements file +RUN pip install --upgrade pip \ + && pip install -r requirements.txt + +# Add a non-root user and switch to it +RUN useradd -m myuser +USER myuser + +# Copy the rest of your application's code with appropriate ownership +COPY --chown=myuser:myuser . /myapp + +# Inform Docker that the container listens on the specified port at runtime. +EXPOSE 8000 + +# Use ENTRYPOINT to specify the executable when the container starts. +# ENTRYPOINT ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/Screenshot (388).png b/Screenshot (388).png new file mode 100644 index 000000000..b0121062b Binary files /dev/null and b/Screenshot (388).png differ diff --git a/alembic.ini b/alembic.ini index 97bf80756..19e9240c6 100644 --- a/alembic.ini +++ b/alembic.ini @@ -1,117 +1,117 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts -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 connection string to your database. -sqlalchemy.url = postgresql://user:password@postgres/myappdb - - -[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 +# A generic, single database configuration. + +[alembic] +# path to migration scripts +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 connection string to your database. +sqlalchemy.url = postgresql://user:password@postgres/myappdb + + +[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 73d3b3ef8..56a52b79f 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,80 +1,80 @@ -from logging.config import fileConfig - -from sqlalchemy import engine_from_config -from sqlalchemy import pool - -from alembic import context -from app.models.user_model import Base # adjust "myapp.models" to the actual location of your Base - - -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# Interpret the config file for Python logging. -# This line sets up loggers basically. -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -# add your model's MetaData object here -# for 'autogenerate' support -# from myapp import mymodel -# target_metadata = mymodel.Base.metadata -target_metadata = Base.metadata - -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. - - -def run_migrations_offline() -> None: - """Run migrations in 'offline' mode. - - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. - - """ - url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url, - target_metadata=target_metadata, - literal_binds=True, - dialect_opts={"paramstyle": "named"}, - ) - - with context.begin_transaction(): - context.run_migrations() - - -def run_migrations_online() -> None: - """Run migrations in 'online' mode. - - In this scenario we need to create an Engine - and associate a connection with the context. - - """ - connectable = engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - - with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) - - with context.begin_transaction(): - context.run_migrations() - - -if context.is_offline_mode(): - run_migrations_offline() -else: +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context +from app.models.user_model import Base # adjust "myapp.models" to the actual location of your Base + + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: run_migrations_online() \ No newline at end of file diff --git a/alembic/script.py.mako b/alembic/script.py.mako index fbc4b07dc..aa5053c91 100644 --- a/alembic/script.py.mako +++ b/alembic/script.py.mako @@ -1,26 +1,26 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision: str = ${repr(up_revision)} -down_revision: Union[str, None] = ${repr(down_revision)} -branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} -depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} - - -def upgrade() -> None: - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - ${downgrades if downgrades else "pass"} +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/ef1d775276c0_initial_migration.py b/alembic/versions/ef1d775276c0_initial_migration.py index 23068895c..0c786d794 100644 --- a/alembic/versions/ef1d775276c0_initial_migration.py +++ b/alembic/versions/ef1d775276c0_initial_migration.py @@ -1,76 +1,76 @@ -"""initial migration - -Revision ID: ef1d775276c0 -Revises: -Create Date: 2024-04-20 21:20:32.839580 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -import uuid - -# revision identifiers, used by Alembic. -revision: str = 'ef1d775276c0' -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('users', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('nickname', sa.String(length=50), nullable=False), - sa.Column('email', sa.String(length=255), nullable=False), - sa.Column('first_name', sa.String(length=100), nullable=True), - sa.Column('last_name', sa.String(length=100), nullable=True), - sa.Column('bio', sa.String(length=500), nullable=True), - sa.Column('profile_picture_url', sa.String(length=255), nullable=True), - sa.Column('linkedin_profile_url', sa.String(length=255), nullable=True), - sa.Column('github_profile_url', sa.String(length=255), nullable=True), - sa.Column('role', sa.Enum('ANONYMOUS', 'AUTHENTICATED', 'MANAGER', 'ADMIN', name='UserRole'), nullable=False), - sa.Column('is_professional', sa.Boolean(), nullable=True), - sa.Column('professional_status_updated_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('failed_login_attempts', sa.Integer(), nullable=True), - sa.Column('is_locked', sa.Boolean(), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), - sa.Column('verification_token', sa.String(), nullable=True), - sa.Column('email_verified', sa.Boolean(), nullable=False), - sa.Column('hashed_password', sa.String(length=255), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) - op.create_index(op.f('ix_users_nickname'), 'users', ['nickname'], unique=True) - # ### end Alembic commands ### - - # ### Add an admin account ### - admin_id = str(uuid.uuid4()) # Generate a UUID for the admin user - admin_email = 'admin@example.com' - admin_nickname = 'admin' - admin_hash_password = '$2b$12$wMygvJfsJGS4UqdeKF1JGO6Sd7tQBg8uo6C946xgntDsstrdgTKVy' - - op.execute(f""" - INSERT INTO users (id, nickname, email, role, email_verified, hashed_password, created_at, updated_at) - VALUES ( - '{admin_id}', - '{admin_nickname}', - '{admin_email}', - 'ADMIN', - TRUE, - '{admin_hash_password}', - now(), - now() - ) - """) - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_users_nickname'), table_name='users') - op.drop_index(op.f('ix_users_email'), table_name='users') - op.drop_table('users') - # ### end Alembic commands ### +"""initial migration + +Revision ID: ef1d775276c0 +Revises: +Create Date: 2024-04-20 21:20:32.839580 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import uuid + +# revision identifiers, used by Alembic. +revision: str = 'ef1d775276c0' +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('users', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('nickname', sa.String(length=50), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('first_name', sa.String(length=100), nullable=True), + sa.Column('last_name', sa.String(length=100), nullable=True), + sa.Column('bio', sa.String(length=500), nullable=True), + sa.Column('profile_picture_url', sa.String(length=255), nullable=True), + sa.Column('linkedin_profile_url', sa.String(length=255), nullable=True), + sa.Column('github_profile_url', sa.String(length=255), nullable=True), + sa.Column('role', sa.Enum('ANONYMOUS', 'AUTHENTICATED', 'MANAGER', 'ADMIN', name='UserRole'), nullable=False), + sa.Column('is_professional', sa.Boolean(), nullable=True), + sa.Column('professional_status_updated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('failed_login_attempts', sa.Integer(), nullable=True), + sa.Column('is_locked', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.Column('verification_token', sa.String(), nullable=True), + sa.Column('email_verified', sa.Boolean(), nullable=False), + sa.Column('hashed_password', sa.String(length=255), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_nickname'), 'users', ['nickname'], unique=True) + # ### end Alembic commands ### + + # ### Add an admin account ### + admin_id = str(uuid.uuid4()) # Generate a UUID for the admin user + admin_email = 'admin@example.com' + admin_nickname = 'admin' + admin_hash_password = '$2b$12$wMygvJfsJGS4UqdeKF1JGO6Sd7tQBg8uo6C946xgntDsstrdgTKVy' + + op.execute(f""" + INSERT INTO users (id, nickname, email, role, email_verified, hashed_password, created_at, updated_at) + VALUES ( + '{admin_id}', + '{admin_nickname}', + '{admin_email}', + 'ADMIN', + TRUE, + '{admin_hash_password}', + now(), + now() + ) + """) + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_users_nickname'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/app/database.py b/app/database.py index a8662934d..75cae29bd 100644 --- a/app/database.py +++ b/app/database.py @@ -1,25 +1,25 @@ -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession -from sqlalchemy.orm import declarative_base, sessionmaker - -Base = declarative_base() - -class Database: - """Handles database connections and sessions.""" - _engine = None - _session_factory = None - - @classmethod - def initialize(cls, database_url: str, echo: bool = False): - """Initialize the async engine and sessionmaker.""" - if cls._engine is None: # Ensure engine is created once - cls._engine = create_async_engine(database_url, echo=echo, future=True) - cls._session_factory = sessionmaker( - bind=cls._engine, class_=AsyncSession, expire_on_commit=False, future=True - ) - - @classmethod - def get_session_factory(cls): - """Returns the session factory, ensuring it's initialized.""" - if cls._session_factory is None: - raise ValueError("Database not initialized. Call `initialize()` first.") - return cls._session_factory +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import declarative_base, sessionmaker + +Base = declarative_base() + +class Database: + """Handles database connections and sessions.""" + _engine = None + _session_factory = None + + @classmethod + def initialize(cls, database_url: str, echo: bool = False): + """Initialize the async engine and sessionmaker.""" + if cls._engine is None: # Ensure engine is created once + cls._engine = create_async_engine(database_url, echo=echo, future=True) + cls._session_factory = sessionmaker( + bind=cls._engine, class_=AsyncSession, expire_on_commit=False, future=True + ) + + @classmethod + def get_session_factory(cls): + """Returns the session factory, ensuring it's initialized.""" + if cls._session_factory is None: + raise ValueError("Database not initialized. Call `initialize()` first.") + return cls._session_factory diff --git a/app/dependencies.py b/app/dependencies.py index 3dd3f7781..7b20801a6 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -1,52 +1,52 @@ -from builtins import Exception, dict, str -from fastapi import Depends, HTTPException -from fastapi.security import OAuth2PasswordBearer -from sqlalchemy.ext.asyncio import AsyncSession -from app.database import Database -from app.utils.template_manager import TemplateManager -from app.services.email_service import EmailService -from app.services.jwt_service import decode_token -from settings.config import Settings -from fastapi import Depends - -def get_settings() -> Settings: - """Return application settings.""" - return Settings() - -def get_email_service() -> EmailService: - template_manager = TemplateManager() - return EmailService(template_manager=template_manager) - -async def get_db() -> AsyncSession: - """Dependency that provides a database session for each request.""" - async_session_factory = Database.get_session_factory() - async with async_session_factory() as session: - try: - yield session - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/login") - -def get_current_user(token: str = Depends(oauth2_scheme)): - credentials_exception = HTTPException( - status_code=401, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - payload = decode_token(token) - if payload is None: - raise credentials_exception - user_id: str = payload.get("sub") - user_role: str = payload.get("role") - if user_id is None or user_role is None: - raise credentials_exception - return {"user_id": user_id, "role": user_role} - -def require_role(role: str): - def role_checker(current_user: dict = Depends(get_current_user)): - if current_user["role"] not in role: - raise HTTPException(status_code=403, detail="Operation not permitted") - return current_user - return role_checker +from builtins import Exception, dict, str +from fastapi import Depends, HTTPException +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import Database +from app.utils.template_manager import TemplateManager +from app.services.email_service import EmailService +from app.services.jwt_service import decode_token +from settings.config import Settings +from fastapi import Depends + +def get_settings() -> Settings: + """Return application settings.""" + return Settings() + +def get_email_service() -> EmailService: + template_manager = TemplateManager() + return EmailService(template_manager=template_manager) + +async def get_db() -> AsyncSession: + """Dependency that provides a database session for each request.""" + async_session_factory = Database.get_session_factory() + async with async_session_factory() as session: + try: + yield session + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/login") + +def get_current_user(token: str = Depends(oauth2_scheme)): + credentials_exception = HTTPException( + status_code=401, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + payload = decode_token(token) + if payload is None: + raise credentials_exception + user_id: str = payload.get("sub") + user_role: str = payload.get("role") + if user_id is None or user_role is None: + raise credentials_exception + return {"user_id": user_id, "role": user_role} + +def require_role(role: str): + def role_checker(current_user: dict = Depends(get_current_user)): + if current_user["role"] not in role: + raise HTTPException(status_code=403, detail="Operation not permitted") + return current_user + return role_checker diff --git a/app/main.py b/app/main.py index 383efe906..fa6fdadac 100644 --- a/app/main.py +++ b/app/main.py @@ -1,31 +1,31 @@ -from builtins import Exception -from fastapi import FastAPI -from starlette.responses import JSONResponse -from app.database import Database -from app.dependencies import get_settings -from app.routers import user_routes -from app.utils.api_description import getDescription -app = FastAPI( - title="User Management", - description=getDescription(), - version="0.0.1", - contact={ - "name": "API Support", - "url": "http://www.example.com/support", - "email": "support@example.com", - }, - license_info={"name": "MIT", "url": "https://opensource.org/licenses/MIT"}, -) - -@app.on_event("startup") -async def startup_event(): - settings = get_settings() - Database.initialize(settings.database_url, settings.debug) - -@app.exception_handler(Exception) -async def exception_handler(request, exc): - return JSONResponse(status_code=500, content={"message": "An unexpected error occurred."}) - -app.include_router(user_routes.router) - - +from builtins import Exception +from fastapi import FastAPI +from starlette.responses import JSONResponse +from app.database import Database +from app.dependencies import get_settings +from app.routers import user_routes +from app.utils.api_description import getDescription +app = FastAPI( + title="User Management", + description=getDescription(), + version="0.0.1", + contact={ + "name": "API Support", + "url": "http://www.example.com/support", + "email": "support@example.com", + }, + license_info={"name": "MIT", "url": "https://opensource.org/licenses/MIT"}, +) + +@app.on_event("startup") +async def startup_event(): + settings = get_settings() + Database.initialize(settings.database_url, settings.debug) + +@app.exception_handler(Exception) +async def exception_handler(request, exc): + return JSONResponse(status_code=500, content={"message": "An unexpected error occurred."}) + +app.include_router(user_routes.router) + + diff --git a/app/models/user_model.py b/app/models/user_model.py index 283bb7ee5..4ddc7aad8 100644 --- a/app/models/user_model.py +++ b/app/models/user_model.py @@ -1,97 +1,97 @@ -from builtins import bool, int, str -from datetime import datetime -from enum import Enum -import uuid -from sqlalchemy import ( - Column, String, Integer, DateTime, Boolean, func, Enum as SQLAlchemyEnum -) -from sqlalchemy.dialects.postgresql import UUID, ENUM -from sqlalchemy.orm import Mapped, mapped_column -from app.database import Base - -class UserRole(Enum): - """Enumeration of user roles within the application, stored as ENUM in the database.""" - ANONYMOUS = "ANONYMOUS" - AUTHENTICATED = "AUTHENTICATED" - MANAGER = "MANAGER" - ADMIN = "ADMIN" - -class User(Base): - """ - Represents a user within the application, corresponding to the 'users' table in the database. - This class uses SQLAlchemy ORM for mapping attributes to database columns efficiently. - - Attributes: - id (UUID): Unique identifier for the user. - nickname (str): Unique nickname for privacy, required. - email (str): Unique email address, required. - email_verified (bool): Flag indicating if the email has been verified. - hashed_password (str): Hashed password for security, required. - first_name (str): Optional first name of the user. - last_name (str): Optional first name of the user. - - bio (str): Optional biographical information. - profile_picture_url (str): Optional URL to a profile picture. - linkedin_profile_url (str): Optional LinkedIn profile URL. - github_profile_url (str): Optional GitHub profile URL. - role (UserRole): Role of the user within the application. - is_professional (bool): Flag indicating professional status. - professional_status_updated_at (datetime): Timestamp of last professional status update. - last_login_at (datetime): Timestamp of the last login. - failed_login_attempts (int): Count of failed login attempts. - is_locked (bool): Flag indicating if the account is locked. - created_at (datetime): Timestamp when the user was created, set by the server. - updated_at (datetime): Timestamp of the last update, set by the server. - - Methods: - lock_account(): Locks the user account. - unlock_account(): Unlocks the user account. - verify_email(): Marks the user's email as verified. - has_role(role_name): Checks if the user has a specified role. - update_professional_status(status): Updates the professional status and logs the update time. - """ - __tablename__ = "users" - __mapper_args__ = {"eager_defaults": True} - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - nickname: Mapped[str] = Column(String(50), unique=True, nullable=False, index=True) - email: Mapped[str] = Column(String(255), unique=True, nullable=False, index=True) - first_name: Mapped[str] = Column(String(100), nullable=True) - last_name: Mapped[str] = Column(String(100), nullable=True) - bio: Mapped[str] = Column(String(500), nullable=True) - profile_picture_url: Mapped[str] = Column(String(255), nullable=True) - linkedin_profile_url: Mapped[str] = Column(String(255), nullable=True) - github_profile_url: Mapped[str] = Column(String(255), nullable=True) - role: Mapped[UserRole] = Column(SQLAlchemyEnum(UserRole, name='UserRole', create_constraint=False), default=UserRole.ANONYMOUS, nullable=False) - is_professional: Mapped[bool] = Column(Boolean, default=False) - professional_status_updated_at: Mapped[datetime] = Column(DateTime(timezone=True), nullable=True) - last_login_at: Mapped[datetime] = Column(DateTime(timezone=True), nullable=True) - failed_login_attempts: Mapped[int] = Column(Integer, default=0) - is_locked: Mapped[bool] = Column(Boolean, default=False) - created_at: Mapped[datetime] = Column(DateTime(timezone=True), server_default=func.now()) - updated_at: Mapped[datetime] = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - verification_token = Column(String, nullable=True) - email_verified: Mapped[bool] = Column(Boolean, default=False, nullable=False) - hashed_password: Mapped[str] = Column(String(255), nullable=False) - - - def __repr__(self) -> str: - """Provides a readable representation of a user object.""" - return f"" - - def lock_account(self): - self.is_locked = True - - def unlock_account(self): - self.is_locked = False - - def verify_email(self): - self.email_verified = True - - def has_role(self, role_name: UserRole) -> bool: - return self.role == role_name - - def update_professional_status(self, status: bool): - """Updates the professional status and logs the update time.""" - self.is_professional = status - self.professional_status_updated_at = func.now() +from builtins import bool, int, str +from datetime import datetime +from enum import Enum +import uuid +from sqlalchemy import ( + Column, String, Integer, DateTime, Boolean, func, Enum as SQLAlchemyEnum +) +from sqlalchemy.dialects.postgresql import UUID, ENUM +from sqlalchemy.orm import Mapped, mapped_column +from app.database import Base + +class UserRole(Enum): + """Enumeration of user roles within the application, stored as ENUM in the database.""" + ANONYMOUS = "ANONYMOUS" + AUTHENTICATED = "AUTHENTICATED" + MANAGER = "MANAGER" + ADMIN = "ADMIN" + +class User(Base): + """ + Represents a user within the application, corresponding to the 'users' table in the database. + This class uses SQLAlchemy ORM for mapping attributes to database columns efficiently. + + Attributes: + id (UUID): Unique identifier for the user. + nickname (str): Unique nickname for privacy, required. + email (str): Unique email address, required. + email_verified (bool): Flag indicating if the email has been verified. + hashed_password (str): Hashed password for security, required. + first_name (str): Optional first name of the user. + last_name (str): Optional first name of the user. + + bio (str): Optional biographical information. + profile_picture_url (str): Optional URL to a profile picture. + linkedin_profile_url (str): Optional LinkedIn profile URL. + github_profile_url (str): Optional GitHub profile URL. + role (UserRole): Role of the user within the application. + is_professional (bool): Flag indicating professional status. + professional_status_updated_at (datetime): Timestamp of last professional status update. + last_login_at (datetime): Timestamp of the last login. + failed_login_attempts (int): Count of failed login attempts. + is_locked (bool): Flag indicating if the account is locked. + created_at (datetime): Timestamp when the user was created, set by the server. + updated_at (datetime): Timestamp of the last update, set by the server. + + Methods: + lock_account(): Locks the user account. + unlock_account(): Unlocks the user account. + verify_email(): Marks the user's email as verified. + has_role(role_name): Checks if the user has a specified role. + update_professional_status(status): Updates the professional status and logs the update time. + """ + __tablename__ = "users" + __mapper_args__ = {"eager_defaults": True} + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + nickname: Mapped[str] = Column(String(50), unique=True, nullable=False, index=True) + email: Mapped[str] = Column(String(255), unique=True, nullable=False, index=True) + first_name: Mapped[str] = Column(String(100), nullable=True) + last_name: Mapped[str] = Column(String(100), nullable=True) + bio: Mapped[str] = Column(String(500), nullable=True) + profile_picture_url: Mapped[str] = Column(String(255), nullable=True) + linkedin_profile_url: Mapped[str] = Column(String(255), nullable=True) + github_profile_url: Mapped[str] = Column(String(255), nullable=True) + role: Mapped[UserRole] = Column(SQLAlchemyEnum(UserRole, name='UserRole', create_constraint=False), default=UserRole.ANONYMOUS, nullable=False) + is_professional: Mapped[bool] = Column(Boolean, default=False) + professional_status_updated_at: Mapped[datetime] = Column(DateTime(timezone=True), nullable=True) + last_login_at: Mapped[datetime] = Column(DateTime(timezone=True), nullable=True) + failed_login_attempts: Mapped[int] = Column(Integer, default=0) + is_locked: Mapped[bool] = Column(Boolean, default=False) + created_at: Mapped[datetime] = Column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + verification_token = Column(String, nullable=True) + email_verified: Mapped[bool] = Column(Boolean, default=False, nullable=False) + hashed_password: Mapped[str] = Column(String(255), nullable=False) + + + def __repr__(self) -> str: + """Provides a readable representation of a user object.""" + return f"" + + def lock_account(self): + self.is_locked = True + + def unlock_account(self): + self.is_locked = False + + def verify_email(self): + self.email_verified = True + + def has_role(self, role_name: UserRole) -> bool: + return self.role == role_name + + def update_professional_status(self, status: bool): + """Updates the professional status and logs the update time.""" + self.is_professional = status + self.professional_status_updated_at = func.now() diff --git a/app/routers/user_routes.py b/app/routers/user_routes.py index 737fd18e7..942ffe5b0 100644 --- a/app/routers/user_routes.py +++ b/app/routers/user_routes.py @@ -1,245 +1,245 @@ -""" -This Python file is part of a FastAPI application, demonstrating user management functionalities including creating, reading, -updating, and deleting (CRUD) user information. It uses OAuth2 with Password Flow for security, ensuring that only authenticated -users can perform certain operations. Additionally, the file showcases the integration of FastAPI with SQLAlchemy for asynchronous -database operations, enhancing performance by non-blocking database calls. - -The implementation emphasizes RESTful API principles, with endpoints for each CRUD operation and the use of HTTP status codes -and exceptions to communicate the outcome of operations. It introduces the concept of HATEOAS (Hypermedia as the Engine of -Application State) by including navigational links in API responses, allowing clients to discover other related operations dynamically. - -OAuth2PasswordBearer is employed to extract the token from the Authorization header and verify the user's identity, providing a layer -of security to the operations that manipulate user data. - -Key Highlights: -- Use of FastAPI's Dependency Injection system to manage database sessions and user authentication. -- Demonstrates how to perform CRUD operations in an asynchronous manner using SQLAlchemy with FastAPI. -- Implements HATEOAS by generating dynamic links for user-related actions, enhancing API discoverability. -- Utilizes OAuth2PasswordBearer for securing API endpoints, requiring valid access tokens for operations. -""" - -from builtins import dict, int, len, str -from datetime import timedelta -from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Response, status, Request -from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from sqlalchemy.ext.asyncio import AsyncSession -from app.dependencies import get_current_user, get_db, get_email_service, require_role -from app.schemas.pagination_schema import EnhancedPagination -from app.schemas.token_schema import TokenResponse -from app.schemas.user_schemas import LoginRequest, UserBase, UserCreate, UserListResponse, UserResponse, UserUpdate -from app.services.user_service import UserService -from app.services.jwt_service import create_access_token -from app.utils.link_generation import create_user_links, generate_pagination_links -from app.dependencies import get_settings -from app.services.email_service import EmailService -router = APIRouter() -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") -settings = get_settings() -@router.get("/users/{user_id}", response_model=UserResponse, name="get_user", tags=["User Management Requires (Admin or Manager Roles)"]) -async def get_user(user_id: UUID, request: Request, db: AsyncSession = Depends(get_db), token: str = Depends(oauth2_scheme), current_user: dict = Depends(require_role(["ADMIN", "MANAGER"]))): - """ - Endpoint to fetch a user by their unique identifier (UUID). - - Utilizes the UserService to query the database asynchronously for the user and constructs a response - model that includes the user's details along with HATEOAS links for possible next actions. - - Args: - user_id: UUID of the user to fetch. - request: The request object, used to generate full URLs in the response. - db: Dependency that provides an AsyncSession for database access. - token: The OAuth2 access token obtained through OAuth2PasswordBearer dependency. - """ - user = await UserService.get_by_id(db, user_id) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - - return UserResponse.model_construct( - id=user.id, - nickname=user.nickname, - first_name=user.first_name, - last_name=user.last_name, - bio=user.bio, - profile_picture_url=user.profile_picture_url, - github_profile_url=user.github_profile_url, - linkedin_profile_url=user.linkedin_profile_url, - role=user.role, - email=user.email, - last_login_at=user.last_login_at, - created_at=user.created_at, - updated_at=user.updated_at, - links=create_user_links(user.id, request) - ) - -# Additional endpoints for update, delete, create, and list users follow a similar pattern, using -# asynchronous database operations, handling security with OAuth2PasswordBearer, and enhancing response -# models with dynamic HATEOAS links. - -# This approach not only ensures that the API is secure and efficient but also promotes a better client -# experience by adhering to REST principles and providing self-discoverable operations. - -@router.put("/users/{user_id}", response_model=UserResponse, name="update_user", tags=["User Management Requires (Admin or Manager Roles)"]) -async def update_user(user_id: UUID, user_update: UserUpdate, request: Request, db: AsyncSession = Depends(get_db), token: str = Depends(oauth2_scheme), current_user: dict = Depends(require_role(["ADMIN", "MANAGER"]))): - """ - Update user information. - - - **user_id**: UUID of the user to update. - - **user_update**: UserUpdate model with updated user information. - """ - user_data = user_update.model_dump(exclude_unset=True) - updated_user = await UserService.update(db, user_id, user_data) - if not updated_user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - - return UserResponse.model_construct( - id=updated_user.id, - bio=updated_user.bio, - first_name=updated_user.first_name, - last_name=updated_user.last_name, - nickname=updated_user.nickname, - email=updated_user.email, - last_login_at=updated_user.last_login_at, - profile_picture_url=updated_user.profile_picture_url, - github_profile_url=updated_user.github_profile_url, - linkedin_profile_url=updated_user.linkedin_profile_url, - created_at=updated_user.created_at, - updated_at=updated_user.updated_at, - links=create_user_links(updated_user.id, request) - ) - - -@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT, name="delete_user", tags=["User Management Requires (Admin or Manager Roles)"]) -async def delete_user(user_id: UUID, db: AsyncSession = Depends(get_db), token: str = Depends(oauth2_scheme), current_user: dict = Depends(require_role(["ADMIN", "MANAGER"]))): - """ - Delete a user by their ID. - - - **user_id**: UUID of the user to delete. - """ - success = await UserService.delete(db, user_id) - if not success: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - return Response(status_code=status.HTTP_204_NO_CONTENT) - - - -@router.post("/users/", response_model=UserResponse, status_code=status.HTTP_201_CREATED, tags=["User Management Requires (Admin or Manager Roles)"], name="create_user") -async def create_user(user: UserCreate, request: Request, db: AsyncSession = Depends(get_db), email_service: EmailService = Depends(get_email_service), token: str = Depends(oauth2_scheme), current_user: dict = Depends(require_role(["ADMIN", "MANAGER"]))): - """ - Create a new user. - - This endpoint creates a new user with the provided information. If the email - already exists, it returns a 400 error. On successful creation, it returns the - newly created user's information along with links to related actions. - - Parameters: - - user (UserCreate): The user information to create. - - request (Request): The request object. - - db (AsyncSession): The database session. - - Returns: - - UserResponse: The newly created user's information along with navigation links. - """ - existing_user = await UserService.get_by_email(db, user.email) - if existing_user: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already exists") - - created_user = await UserService.create(db, user.model_dump(), email_service) - if not created_user: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create user") - - - return UserResponse.model_construct( - id=created_user.id, - bio=created_user.bio, - first_name=created_user.first_name, - last_name=created_user.last_name, - profile_picture_url=created_user.profile_picture_url, - nickname=created_user.nickname, - email=created_user.email, - last_login_at=created_user.last_login_at, - created_at=created_user.created_at, - updated_at=created_user.updated_at, - links=create_user_links(created_user.id, request) - ) - - -@router.get("/users/", response_model=UserListResponse, tags=["User Management Requires (Admin or Manager Roles)"]) -async def list_users( - request: Request, - skip: int = 0, - limit: int = 10, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(require_role(["ADMIN", "MANAGER"])) -): - total_users = await UserService.count(db) - users = await UserService.list_users(db, skip, limit) - - user_responses = [ - UserResponse.model_validate(user) for user in users - ] - - pagination_links = generate_pagination_links(request, skip, limit, total_users) - - # Construct the final response with pagination details - return UserListResponse( - items=user_responses, - total=total_users, - page=skip // limit + 1, - size=len(user_responses), - links=pagination_links # Ensure you have appropriate logic to create these links - ) - - -@router.post("/register/", response_model=UserResponse, tags=["Login and Registration"]) -async def register(user_data: UserCreate, session: AsyncSession = Depends(get_db), email_service: EmailService = Depends(get_email_service)): - user = await UserService.register_user(session, user_data.model_dump(), email_service) - if user: - return user - raise HTTPException(status_code=400, detail="Email already exists") - -@router.post("/login/", response_model=TokenResponse, tags=["Login and Registration"]) -async def login(form_data: OAuth2PasswordRequestForm = Depends(), session: AsyncSession = Depends(get_db)): - if await UserService.is_account_locked(session, form_data.username): - raise HTTPException(status_code=400, detail="Account locked due to too many failed login attempts.") - - user = await UserService.login_user(session, form_data.username, form_data.password) - if user: - access_token_expires = timedelta(minutes=settings.access_token_expire_minutes) - - access_token = create_access_token( - data={"sub": user.email, "role": str(user.role.name)}, - expires_delta=access_token_expires - ) - - return {"access_token": access_token, "token_type": "bearer"} - raise HTTPException(status_code=401, detail="The email or password is incorrect, the email is not verified, or the account is locked.") - -@router.post("/login/", include_in_schema=False, response_model=TokenResponse, tags=["Login and Registration"]) -async def login(form_data: OAuth2PasswordRequestForm = Depends(), session: AsyncSession = Depends(get_db)): - if await UserService.is_account_locked(session, form_data.username): - raise HTTPException(status_code=400, detail="Account locked due to too many failed login attempts.") - - user = await UserService.login_user(session, form_data.username, form_data.password) - if user: - access_token_expires = timedelta(minutes=settings.access_token_expire_minutes) - - access_token = create_access_token( - data={"sub": user.email, "role": str(user.role.name)}, - expires_delta=access_token_expires - ) - - return {"access_token": access_token, "token_type": "bearer"} - raise HTTPException(status_code=401, detail="The email or password is incorrect, the email is not verified, or the account is locked.") - - -@router.get("/verify-email/{user_id}/{token}", status_code=status.HTTP_200_OK, name="verify_email", tags=["Login and Registration"]) -async def verify_email(user_id: UUID, token: str, db: AsyncSession = Depends(get_db), email_service: EmailService = Depends(get_email_service)): - """ - Verify user's email with a provided token. - - - **user_id**: UUID of the user to verify. - - **token**: Verification token sent to the user's email. - """ - if await UserService.verify_email_with_token(db, user_id, token): - return {"message": "Email verified successfully"} +""" +This Python file is part of a FastAPI application, demonstrating user management functionalities including creating, reading, +updating, and deleting (CRUD) user information. It uses OAuth2 with Password Flow for security, ensuring that only authenticated +users can perform certain operations. Additionally, the file showcases the integration of FastAPI with SQLAlchemy for asynchronous +database operations, enhancing performance by non-blocking database calls. + +The implementation emphasizes RESTful API principles, with endpoints for each CRUD operation and the use of HTTP status codes +and exceptions to communicate the outcome of operations. It introduces the concept of HATEOAS (Hypermedia as the Engine of +Application State) by including navigational links in API responses, allowing clients to discover other related operations dynamically. + +OAuth2PasswordBearer is employed to extract the token from the Authorization header and verify the user's identity, providing a layer +of security to the operations that manipulate user data. + +Key Highlights: +- Use of FastAPI's Dependency Injection system to manage database sessions and user authentication. +- Demonstrates how to perform CRUD operations in an asynchronous manner using SQLAlchemy with FastAPI. +- Implements HATEOAS by generating dynamic links for user-related actions, enhancing API discoverability. +- Utilizes OAuth2PasswordBearer for securing API endpoints, requiring valid access tokens for operations. +""" + +from builtins import dict, int, len, str +from datetime import timedelta +from uuid import UUID +from fastapi import APIRouter, Depends, HTTPException, Response, status, Request +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from sqlalchemy.ext.asyncio import AsyncSession +from app.dependencies import get_current_user, get_db, get_email_service, require_role +from app.schemas.pagination_schema import EnhancedPagination +from app.schemas.token_schema import TokenResponse +from app.schemas.user_schemas import LoginRequest, UserBase, UserCreate, UserListResponse, UserResponse, UserUpdate +from app.services.user_service import UserService +from app.services.jwt_service import create_access_token +from app.utils.link_generation import create_user_links, generate_pagination_links +from app.dependencies import get_settings +from app.services.email_service import EmailService +router = APIRouter() +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") +settings = get_settings() +@router.get("/users/{user_id}", response_model=UserResponse, name="get_user", tags=["User Management Requires (Admin or Manager Roles)"]) +async def get_user(user_id: UUID, request: Request, db: AsyncSession = Depends(get_db), token: str = Depends(oauth2_scheme), current_user: dict = Depends(require_role(["ADMIN", "MANAGER"]))): + """ + Endpoint to fetch a user by their unique identifier (UUID). + + Utilizes the UserService to query the database asynchronously for the user and constructs a response + model that includes the user's details along with HATEOAS links for possible next actions. + + Args: + user_id: UUID of the user to fetch. + request: The request object, used to generate full URLs in the response. + db: Dependency that provides an AsyncSession for database access. + token: The OAuth2 access token obtained through OAuth2PasswordBearer dependency. + """ + user = await UserService.get_by_id(db, user_id) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + return UserResponse.model_construct( + id=user.id, + nickname=user.nickname, + first_name=user.first_name, + last_name=user.last_name, + bio=user.bio, + profile_picture_url=user.profile_picture_url, + github_profile_url=user.github_profile_url, + linkedin_profile_url=user.linkedin_profile_url, + role=user.role, + email=user.email, + last_login_at=user.last_login_at, + created_at=user.created_at, + updated_at=user.updated_at, + links=create_user_links(user.id, request) + ) + +# Additional endpoints for update, delete, create, and list users follow a similar pattern, using +# asynchronous database operations, handling security with OAuth2PasswordBearer, and enhancing response +# models with dynamic HATEOAS links. + +# This approach not only ensures that the API is secure and efficient but also promotes a better client +# experience by adhering to REST principles and providing self-discoverable operations. + +@router.put("/users/{user_id}", response_model=UserResponse, name="update_user", tags=["User Management Requires (Admin or Manager Roles)"]) +async def update_user(user_id: UUID, user_update: UserUpdate, request: Request, db: AsyncSession = Depends(get_db), token: str = Depends(oauth2_scheme), current_user: dict = Depends(require_role(["ADMIN", "MANAGER"]))): + """ + Update user information. + + - **user_id**: UUID of the user to update. + - **user_update**: UserUpdate model with updated user information. + """ + user_data = user_update.model_dump(exclude_unset=True) + updated_user = await UserService.update(db, user_id, user_data) + if not updated_user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + return UserResponse.model_construct( + id=updated_user.id, + bio=updated_user.bio, + first_name=updated_user.first_name, + last_name=updated_user.last_name, + nickname=updated_user.nickname, + email=updated_user.email, + last_login_at=updated_user.last_login_at, + profile_picture_url=updated_user.profile_picture_url, + github_profile_url=updated_user.github_profile_url, + linkedin_profile_url=updated_user.linkedin_profile_url, + created_at=updated_user.created_at, + updated_at=updated_user.updated_at, + links=create_user_links(updated_user.id, request) + ) + + +@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT, name="delete_user", tags=["User Management Requires (Admin or Manager Roles)"]) +async def delete_user(user_id: UUID, db: AsyncSession = Depends(get_db), token: str = Depends(oauth2_scheme), current_user: dict = Depends(require_role(["ADMIN", "MANAGER"]))): + """ + Delete a user by their ID. + + - **user_id**: UUID of the user to delete. + """ + success = await UserService.delete(db, user_id) + if not success: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + return Response(status_code=status.HTTP_204_NO_CONTENT) + + + +@router.post("/users/", response_model=UserResponse, status_code=status.HTTP_201_CREATED, tags=["User Management Requires (Admin or Manager Roles)"], name="create_user") +async def create_user(user: UserCreate, request: Request, db: AsyncSession = Depends(get_db), email_service: EmailService = Depends(get_email_service), token: str = Depends(oauth2_scheme), current_user: dict = Depends(require_role(["ADMIN", "MANAGER"]))): + """ + Create a new user. + + This endpoint creates a new user with the provided information. If the email + already exists, it returns a 400 error. On successful creation, it returns the + newly created user's information along with links to related actions. + + Parameters: + - user (UserCreate): The user information to create. + - request (Request): The request object. + - db (AsyncSession): The database session. + + Returns: + - UserResponse: The newly created user's information along with navigation links. + """ + existing_user = await UserService.get_by_email(db, user.email) + if existing_user: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already exists") + + created_user = await UserService.create(db, user.model_dump(), email_service) + if not created_user: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create user") + + + return UserResponse.model_construct( + id=created_user.id, + bio=created_user.bio, + first_name=created_user.first_name, + last_name=created_user.last_name, + profile_picture_url=created_user.profile_picture_url, + nickname=created_user.nickname, + email=created_user.email, + last_login_at=created_user.last_login_at, + created_at=created_user.created_at, + updated_at=created_user.updated_at, + links=create_user_links(created_user.id, request) + ) + + +@router.get("/users/", response_model=UserListResponse, tags=["User Management Requires (Admin or Manager Roles)"]) +async def list_users( + request: Request, + skip: int = 0, + limit: int = 10, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_role(["ADMIN", "MANAGER"])) +): + total_users = await UserService.count(db) + users = await UserService.list_users(db, skip, limit) + + user_responses = [ + UserResponse.model_validate(user) for user in users + ] + + pagination_links = generate_pagination_links(request, skip, limit, total_users) + + # Construct the final response with pagination details + return UserListResponse( + items=user_responses, + total=total_users, + page=skip // limit + 1, + size=len(user_responses), + links=pagination_links # Ensure you have appropriate logic to create these links + ) + + +@router.post("/register/", response_model=UserResponse, tags=["Login and Registration"]) +async def register(user_data: UserCreate, session: AsyncSession = Depends(get_db), email_service: EmailService = Depends(get_email_service)): + user = await UserService.register_user(session, user_data.model_dump(), email_service) + if user: + return user + raise HTTPException(status_code=400, detail="Email already exists") + +@router.post("/login/", response_model=TokenResponse, tags=["Login and Registration"]) +async def login(form_data: OAuth2PasswordRequestForm = Depends(), session: AsyncSession = Depends(get_db)): + if await UserService.is_account_locked(session, form_data.username): + raise HTTPException(status_code=400, detail="Account locked due to too many failed login attempts.") + + user = await UserService.login_user(session, form_data.username, form_data.password) + if user: + access_token_expires = timedelta(minutes=settings.access_token_expire_minutes) + + access_token = create_access_token( + data={"sub": user.email, "role": str(user.role.name)}, + expires_delta=access_token_expires + ) + + return {"access_token": access_token, "token_type": "bearer"} + raise HTTPException(status_code=401, detail="The email or password is incorrect, the email is not verified, or the account is locked.") + +@router.post("/login/", include_in_schema=False, response_model=TokenResponse, tags=["Login and Registration"]) +async def login(form_data: OAuth2PasswordRequestForm = Depends(), session: AsyncSession = Depends(get_db)): + if await UserService.is_account_locked(session, form_data.username): + raise HTTPException(status_code=400, detail="Account locked due to too many failed login attempts.") + + user = await UserService.login_user(session, form_data.username, form_data.password) + if user: + access_token_expires = timedelta(minutes=settings.access_token_expire_minutes) + + access_token = create_access_token( + data={"sub": user.email, "role": str(user.role.name)}, + expires_delta=access_token_expires + ) + + return {"access_token": access_token, "token_type": "bearer"} + raise HTTPException(status_code=401, detail="The email or password is incorrect, the email is not verified, or the account is locked.") + + +@router.get("/verify-email/{user_id}/{token}", status_code=status.HTTP_200_OK, name="verify_email", tags=["Login and Registration"]) +async def verify_email(user_id: UUID, token: str, db: AsyncSession = Depends(get_db), email_service: EmailService = Depends(get_email_service)): + """ + Verify user's email with a provided token. + + - **user_id**: UUID of the user to verify. + - **token**: Verification token sent to the user's email. + """ + if await UserService.verify_email_with_token(db, user_id, token): + return {"message": "Email verified successfully"} raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired verification token") \ No newline at end of file diff --git a/app/schemas/link_schema.py b/app/schemas/link_schema.py index 2cd90d22f..356cd057a 100644 --- a/app/schemas/link_schema.py +++ b/app/schemas/link_schema.py @@ -1,17 +1,17 @@ -from pydantic import BaseModel, Field, HttpUrl - -class Link(BaseModel): - rel: str = Field(..., description="Relation type of the link.") - href: HttpUrl = Field(..., description="The URL of the link.") - action: str = Field(..., description="HTTP method for the action this link represents.") - type: str = Field(default="application/json", description="Content type of the response for this link.") - - class Config: - json_schema_extra = { - "example": { - "rel": "self", - "href": "https://api.example.com/qr/123", - "action": "GET", - "type": "application/json" - } - } +from pydantic import BaseModel, Field, HttpUrl + +class Link(BaseModel): + rel: str = Field(..., description="Relation type of the link.") + href: HttpUrl = Field(..., description="The URL of the link.") + action: str = Field(..., description="HTTP method for the action this link represents.") + type: str = Field(default="application/json", description="Content type of the response for this link.") + + class Config: + json_schema_extra = { + "example": { + "rel": "self", + "href": "https://api.example.com/qr/123", + "action": "GET", + "type": "application/json" + } + } diff --git a/app/schemas/pagination_schema.py b/app/schemas/pagination_schema.py index 263aa209b..3956dca43 100644 --- a/app/schemas/pagination_schema.py +++ b/app/schemas/pagination_schema.py @@ -1,35 +1,35 @@ -import re -from datetime import datetime -from typing import List, Optional -from uuid import UUID -from pydantic import BaseModel, EmailStr, Field, HttpUrl, validator, conint - -# Pagination Model -class Pagination(BaseModel): - page: int = Field(..., description="Current page number.") - per_page: int = Field(..., description="Number of items per page.") - total_items: int = Field(..., description="Total number of items.") - total_pages: int = Field(..., description="Total number of pages.") - - class Config: - json_schema_extra = { - "example": { - "page": 1, - "per_page": 10, - "total_items": 50, - "total_pages": 5 - } - } - - - -class PaginationLink(BaseModel): - rel: str - href: HttpUrl - method: str = "GET" - -class EnhancedPagination(Pagination): - links: List[PaginationLink] = [] - - def add_link(self, rel: str, href: str): - self.links.append(PaginationLink(rel=rel, href=href)) +import re +from datetime import datetime +from typing import List, Optional +from uuid import UUID +from pydantic import BaseModel, EmailStr, Field, HttpUrl, validator, conint + +# Pagination Model +class Pagination(BaseModel): + page: int = Field(..., description="Current page number.") + per_page: int = Field(..., description="Number of items per page.") + total_items: int = Field(..., description="Total number of items.") + total_pages: int = Field(..., description="Total number of pages.") + + class Config: + json_schema_extra = { + "example": { + "page": 1, + "per_page": 10, + "total_items": 50, + "total_pages": 5 + } + } + + + +class PaginationLink(BaseModel): + rel: str + href: HttpUrl + method: str = "GET" + +class EnhancedPagination(Pagination): + links: List[PaginationLink] = [] + + def add_link(self, rel: str, href: str): + self.links.append(PaginationLink(rel=rel, href=href)) diff --git a/app/schemas/token_schema.py b/app/schemas/token_schema.py index 991d85252..af66f03cb 100644 --- a/app/schemas/token_schema.py +++ b/app/schemas/token_schema.py @@ -1,14 +1,14 @@ -from builtins import str -from pydantic import BaseModel - -class TokenResponse(BaseModel): - access_token: str - token_type: str = "bearer" - - class Config: - schema_extra = { - "example": { - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJqb2huZG9lQGV4YW1wbGUuY29tIiwicm9sZSI6IkFVVEhFTlRJQ0FURUQiLCJleHAiOjE2MjQzMzQ5ODR9.ZGNjNjI2ZjI4MmYzNTk0MjVjNDk0ZjI4MjdjNGEzNmI1", - "token_type": "bearer" - } +from builtins import str +from pydantic import BaseModel + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + class Config: + schema_extra = { + "example": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJqb2huZG9lQGV4YW1wbGUuY29tIiwicm9sZSI6IkFVVEhFTlRJQ0FURUQiLCJleHAiOjE2MjQzMzQ5ODR9.ZGNjNjI2ZjI4MmYzNTk0MjVjNDk0ZjI4MjdjNGEzNmI1", + "token_type": "bearer" + } } \ No newline at end of file diff --git a/app/schemas/user_schemas.py b/app/schemas/user_schemas.py index 7877378cf..c5d7e0ccc 100644 --- a/app/schemas/user_schemas.py +++ b/app/schemas/user_schemas.py @@ -1,87 +1,87 @@ -from builtins import ValueError, any, bool, str -from pydantic import BaseModel, EmailStr, Field, validator, root_validator -from typing import Optional, List -from datetime import datetime -from enum import Enum -import uuid -import re - -from app.utils.nickname_gen import generate_nickname - -class UserRole(str, Enum): - ANONYMOUS = "ANONYMOUS" - AUTHENTICATED = "AUTHENTICATED" - MANAGER = "MANAGER" - ADMIN = "ADMIN" - -def validate_url(url: Optional[str]) -> Optional[str]: - if url is None: - return url - url_regex = r'^https?:\/\/[^\s/$.?#].[^\s]*$' - if not re.match(url_regex, url): - raise ValueError('Invalid URL format') - return url - -class UserBase(BaseModel): - email: EmailStr = Field(..., example="john.doe@example.com") - nickname: Optional[str] = Field(None, min_length=3, pattern=r'^[\w-]+$', example=generate_nickname()) - first_name: Optional[str] = Field(None, example="John") - last_name: Optional[str] = Field(None, example="Doe") - bio: Optional[str] = Field(None, example="Experienced software developer specializing in web applications.") - profile_picture_url: Optional[str] = Field(None, example="https://example.com/profiles/john.jpg") - linkedin_profile_url: Optional[str] =Field(None, example="https://linkedin.com/in/johndoe") - github_profile_url: Optional[str] = Field(None, example="https://github.com/johndoe") - - _validate_urls = validator('profile_picture_url', 'linkedin_profile_url', 'github_profile_url', pre=True, allow_reuse=True)(validate_url) - - class Config: - from_attributes = True - -class UserCreate(UserBase): - email: EmailStr = Field(..., example="john.doe@example.com") - password: str = Field(..., example="Secure*1234") - -class UserUpdate(UserBase): - email: Optional[EmailStr] = Field(None, example="john.doe@example.com") - nickname: Optional[str] = Field(None, min_length=3, pattern=r'^[\w-]+$', example="john_doe123") - first_name: Optional[str] = Field(None, example="John") - last_name: Optional[str] = Field(None, example="Doe") - bio: Optional[str] = Field(None, example="Experienced software developer specializing in web applications.") - profile_picture_url: Optional[str] = Field(None, example="https://example.com/profiles/john.jpg") - linkedin_profile_url: Optional[str] =Field(None, example="https://linkedin.com/in/johndoe") - github_profile_url: Optional[str] = Field(None, example="https://github.com/johndoe") - - @root_validator(pre=True) - def check_at_least_one_value(cls, values): - if not any(values.values()): - raise ValueError("At least one field must be provided for update") - return values - -class UserResponse(UserBase): - id: uuid.UUID = Field(..., example=uuid.uuid4()) - role: UserRole = Field(default=UserRole.AUTHENTICATED, example="AUTHENTICATED") - email: EmailStr = Field(..., example="john.doe@example.com") - nickname: Optional[str] = Field(None, min_length=3, pattern=r'^[\w-]+$', example=generate_nickname()) - role: UserRole = Field(default=UserRole.AUTHENTICATED, example="AUTHENTICATED") - is_professional: Optional[bool] = Field(default=False, example=True) - -class LoginRequest(BaseModel): - email: str = Field(..., example="john.doe@example.com") - password: str = Field(..., example="Secure*1234") - -class ErrorResponse(BaseModel): - error: str = Field(..., example="Not Found") - details: Optional[str] = Field(None, example="The requested resource was not found.") - -class UserListResponse(BaseModel): - items: List[UserResponse] = Field(..., example=[{ - "id": uuid.uuid4(), "nickname": generate_nickname(), "email": "john.doe@example.com", - "first_name": "John", "bio": "Experienced developer", "role": "AUTHENTICATED", - "last_name": "Doe", "bio": "Experienced developer", "role": "AUTHENTICATED", - "profile_picture_url": "https://example.com/profiles/john.jpg", - "linkedin_profile_url": "https://linkedin.com/in/johndoe", - "github_profile_url": "https://github.com/johndoe" - }]) - total: int = Field(..., example=100) - page: int = Field(..., example=1) - size: int = Field(..., example=10) +from builtins import ValueError, any, bool, str +from pydantic import BaseModel, EmailStr, Field, validator, root_validator +from typing import Optional, List +from datetime import datetime +from enum import Enum +import uuid +import re + +from app.utils.nickname_gen import generate_nickname + +class UserRole(str, Enum): + ANONYMOUS = "ANONYMOUS" + AUTHENTICATED = "AUTHENTICATED" + MANAGER = "MANAGER" + ADMIN = "ADMIN" + +def validate_url(url: Optional[str]) -> Optional[str]: + if url is None: + return url + url_regex = r'^https?:\/\/[^\s/$.?#].[^\s]*$' + if not re.match(url_regex, url): + raise ValueError('Invalid URL format') + return url + +class UserBase(BaseModel): + email: EmailStr = Field(..., example="john.doe@example.com") + nickname: Optional[str] = Field(None, min_length=3, pattern=r'^[\w-]+$', example=generate_nickname()) + first_name: Optional[str] = Field(None, example="John") + last_name: Optional[str] = Field(None, example="Doe") + bio: Optional[str] = Field(None, example="Experienced software developer specializing in web applications.") + profile_picture_url: Optional[str] = Field(None, example="https://example.com/profiles/john.jpg") + linkedin_profile_url: Optional[str] =Field(None, example="https://linkedin.com/in/johndoe") + github_profile_url: Optional[str] = Field(None, example="https://github.com/johndoe") + + _validate_urls = validator('profile_picture_url', 'linkedin_profile_url', 'github_profile_url', pre=True, allow_reuse=True)(validate_url) + + class Config: + from_attributes = True + +class UserCreate(UserBase): + email: EmailStr = Field(..., example="john.doe@example.com") + password: str = Field(..., example="Secure*1234") + +class UserUpdate(UserBase): + email: Optional[EmailStr] = Field(None, example="john.doe@example.com") + nickname: Optional[str] = Field(None, min_length=3, pattern=r'^[\w-]+$', example="john_doe123") + first_name: Optional[str] = Field(None, example="John") + last_name: Optional[str] = Field(None, example="Doe") + bio: Optional[str] = Field(None, example="Experienced software developer specializing in web applications.") + profile_picture_url: Optional[str] = Field(None, example="https://example.com/profiles/john.jpg") + linkedin_profile_url: Optional[str] =Field(None, example="https://linkedin.com/in/johndoe") + github_profile_url: Optional[str] = Field(None, example="https://github.com/johndoe") + + @root_validator(pre=True) + def check_at_least_one_value(cls, values): + if not any(values.values()): + raise ValueError("At least one field must be provided for update") + return values + +class UserResponse(UserBase): + id: uuid.UUID = Field(..., example=uuid.uuid4()) + role: UserRole = Field(default=UserRole.AUTHENTICATED, example="AUTHENTICATED") + email: EmailStr = Field(..., example="john.doe@example.com") + nickname: Optional[str] = Field(None, min_length=3, pattern=r'^[\w-]+$', example=generate_nickname()) + role: UserRole = Field(default=UserRole.AUTHENTICATED, example="AUTHENTICATED") + is_professional: Optional[bool] = Field(default=False, example=True) + +class LoginRequest(BaseModel): + email: str = Field(..., example="john.doe@example.com") + password: str = Field(..., example="Secure*1234") + +class ErrorResponse(BaseModel): + error: str = Field(..., example="Not Found") + details: Optional[str] = Field(None, example="The requested resource was not found.") + +class UserListResponse(BaseModel): + items: List[UserResponse] = Field(..., example=[{ + "id": uuid.uuid4(), "nickname": generate_nickname(), "email": "john.doe@example.com", + "first_name": "John", "bio": "Experienced developer", "role": "AUTHENTICATED", + "last_name": "Doe", "bio": "Experienced developer", "role": "AUTHENTICATED", + "profile_picture_url": "https://example.com/profiles/john.jpg", + "linkedin_profile_url": "https://linkedin.com/in/johndoe", + "github_profile_url": "https://github.com/johndoe" + }]) + total: int = Field(..., example=100) + page: int = Field(..., example=1) + size: int = Field(..., example=10) diff --git a/app/services/email_service.py b/app/services/email_service.py index 620b05cee..0301f31c9 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -1,45 +1,45 @@ -# email_service.py -from builtins import ValueError, dict, str -from settings.config import settings -from app.utils.smtp_connection import SMTPClient -from app.utils.template_manager import TemplateManager -from app.models.user_model import User - -class EmailService: - def __init__(self, template_manager: TemplateManager): - if not settings.smtp_server or not settings.smtp_port or not settings.smtp_username or not settings.smtp_password: - print("SMTP settings not configured. Email service will not work.") - self.smtp_client = None - else: - self.smtp_client = SMTPClient( - server=settings.smtp_server, - port=settings.smtp_port, - username=settings.smtp_username, - password=settings.smtp_password - ) - self.template_manager = template_manager - - async def send_user_email(self, user_data: dict, email_type: str): - if not self.smtp_client: - return - subject_map = { - 'email_verification': "Verify Your Account", - 'password_reset': "Password Reset Instructions", - 'account_locked': "Account Locked Notification" - } - - if email_type not in subject_map: - raise ValueError("Invalid email type") - - html_content = self.template_manager.render_template(email_type, **user_data) - self.smtp_client.send_email(subject_map[email_type], html_content, user_data['email']) - - async def send_verification_email(self, user: User): - if not self.smtp_client: - return - verification_url = f"{settings.server_base_url}verify-email/{user.id}/{user.verification_token}" - await self.send_user_email({ - "name": user.first_name, - "verification_url": verification_url, - "email": user.email +# email_service.py +from builtins import ValueError, dict, str +from settings.config import settings +from app.utils.smtp_connection import SMTPClient +from app.utils.template_manager import TemplateManager +from app.models.user_model import User + +class EmailService: + def __init__(self, template_manager: TemplateManager): + if not settings.smtp_server or not settings.smtp_port or not settings.smtp_username or not settings.smtp_password: + print("SMTP settings not configured. Email service will not work.") + self.smtp_client = None + else: + self.smtp_client = SMTPClient( + server=settings.smtp_server, + port=settings.smtp_port, + username=settings.smtp_username, + password=settings.smtp_password + ) + self.template_manager = template_manager + + async def send_user_email(self, user_data: dict, email_type: str): + if not self.smtp_client: + return + subject_map = { + 'email_verification': "Verify Your Account", + 'password_reset': "Password Reset Instructions", + 'account_locked': "Account Locked Notification" + } + + if email_type not in subject_map: + raise ValueError("Invalid email type") + + html_content = self.template_manager.render_template(email_type, **user_data) + self.smtp_client.send_email(subject_map[email_type], html_content, user_data['email']) + + async def send_verification_email(self, user: User): + if not self.smtp_client: + return + verification_url = f"{settings.server_base_url}verify-email/{user.id}/{user.verification_token}" + await self.send_user_email({ + "name": user.first_name, + "verification_url": verification_url, + "email": user.email }, 'email_verification') \ No newline at end of file diff --git a/app/services/jwt_service.py b/app/services/jwt_service.py index c19b8fcc3..b0a898453 100644 --- a/app/services/jwt_service.py +++ b/app/services/jwt_service.py @@ -1,22 +1,22 @@ -# app/services/jwt_service.py -from builtins import dict, str -import jwt -from datetime import datetime, timedelta -from settings.config import settings - -def create_access_token(*, data: dict, expires_delta: timedelta = None): - to_encode = data.copy() - # Convert role to uppercase before encoding the JWT - if 'role' in to_encode: - to_encode['role'] = to_encode['role'].upper() - expire = datetime.utcnow() + (expires_delta if expires_delta else timedelta(minutes=settings.access_token_expire_minutes)) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) - return encoded_jwt - -def decode_token(token: str): - try: - decoded = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]) - return decoded - except jwt.PyJWTError: - return None +# app/services/jwt_service.py +from builtins import dict, str +import jwt +from datetime import datetime, timedelta +from settings.config import settings + +def create_access_token(*, data: dict, expires_delta: timedelta = None): + to_encode = data.copy() + # Convert role to uppercase before encoding the JWT + if 'role' in to_encode: + to_encode['role'] = to_encode['role'].upper() + expire = datetime.utcnow() + (expires_delta if expires_delta else timedelta(minutes=settings.access_token_expire_minutes)) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) + return encoded_jwt + +def decode_token(token: str): + try: + decoded = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]) + return decoded + except jwt.PyJWTError: + return None diff --git a/app/services/user_service.py b/app/services/user_service.py index e22842505..242e53108 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -1,194 +1,194 @@ -from builtins import Exception, bool, classmethod, int, str -from datetime import datetime, timezone -import secrets -from typing import Optional, Dict, List -from pydantic import ValidationError -from sqlalchemy import func, null, update, select -from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.ext.asyncio import AsyncSession -from app.dependencies import get_email_service, get_settings -from app.models.user_model import User -from app.schemas.user_schemas import UserCreate, UserUpdate -from app.utils.nickname_gen import generate_nickname -from app.utils.security import generate_verification_token, hash_password, verify_password -from uuid import UUID -from app.services.email_service import EmailService -from app.models.user_model import UserRole -import logging - -settings = get_settings() -logger = logging.getLogger(__name__) - -class UserService: - @classmethod - async def _execute_query(cls, session: AsyncSession, query): - try: - result = await session.execute(query) - await session.commit() - return result - except SQLAlchemyError as e: - logger.error(f"Database error: {e}") - await session.rollback() - return None - - @classmethod - async def _fetch_user(cls, session: AsyncSession, **filters) -> Optional[User]: - query = select(User).filter_by(**filters) - result = await cls._execute_query(session, query) - return result.scalars().first() if result else None - - @classmethod - async def get_by_id(cls, session: AsyncSession, user_id: UUID) -> Optional[User]: - return await cls._fetch_user(session, id=user_id) - - @classmethod - async def get_by_nickname(cls, session: AsyncSession, nickname: str) -> Optional[User]: - return await cls._fetch_user(session, nickname=nickname) - - @classmethod - async def get_by_email(cls, session: AsyncSession, email: str) -> Optional[User]: - return await cls._fetch_user(session, email=email) - - @classmethod - async def create(cls, session: AsyncSession, user_data: Dict[str, str], email_service: EmailService) -> Optional[User]: - try: - validated_data = UserCreate(**user_data).model_dump() - existing_user = await cls.get_by_email(session, validated_data['email']) - if existing_user: - logger.error("User with given email already exists.") - return None - validated_data['hashed_password'] = hash_password(validated_data.pop('password')) - new_user = User(**validated_data) - new_user.verification_token = generate_verification_token() - new_nickname = generate_nickname() - while await cls.get_by_nickname(session, new_nickname): - new_nickname = generate_nickname() - new_user.nickname = new_nickname - session.add(new_user) - await session.commit() - await email_service.send_verification_email(new_user) - - return new_user - except ValidationError as e: - logger.error(f"Validation error during user creation: {e}") - return None - - @classmethod - async def update(cls, session: AsyncSession, user_id: UUID, update_data: Dict[str, str]) -> Optional[User]: - try: - # validated_data = UserUpdate(**update_data).dict(exclude_unset=True) - validated_data = UserUpdate(**update_data).dict(exclude_unset=True) - - if 'password' in validated_data: - validated_data['hashed_password'] = hash_password(validated_data.pop('password')) - query = update(User).where(User.id == user_id).values(**validated_data).execution_options(synchronize_session="fetch") - await cls._execute_query(session, query) - updated_user = await cls.get_by_id(session, user_id) - if updated_user: - session.refresh(updated_user) # Explicitly refresh the updated user object - logger.info(f"User {user_id} updated successfully.") - return updated_user - else: - logger.error(f"User {user_id} not found after update attempt.") - return None - except Exception as e: # Broad exception handling for debugging - logger.error(f"Error during user update: {e}") - return None - - @classmethod - async def delete(cls, session: AsyncSession, user_id: UUID) -> bool: - user = await cls.get_by_id(session, user_id) - if not user: - logger.info(f"User with ID {user_id} not found.") - return False - await session.delete(user) - await session.commit() - return True - - @classmethod - async def list_users(cls, session: AsyncSession, skip: int = 0, limit: int = 10) -> List[User]: - query = select(User).offset(skip).limit(limit) - result = await cls._execute_query(session, query) - return result.scalars().all() if result else [] - - @classmethod - async def register_user(cls, session: AsyncSession, user_data: Dict[str, str], get_email_service) -> Optional[User]: - return await cls.create(session, user_data, get_email_service) - - - @classmethod - async def login_user(cls, session: AsyncSession, email: str, password: str) -> Optional[User]: - user = await cls.get_by_email(session, email) - if user: - if user.email_verified is False: - return None - if user.is_locked: - return None - if verify_password(password, user.hashed_password): - user.failed_login_attempts = 0 - user.last_login_at = datetime.now(timezone.utc) - session.add(user) - await session.commit() - return user - else: - user.failed_login_attempts += 1 - if user.failed_login_attempts >= settings.max_login_attempts: - user.is_locked = True - session.add(user) - await session.commit() - return None - - @classmethod - async def is_account_locked(cls, session: AsyncSession, email: str) -> bool: - user = await cls.get_by_email(session, email) - return user.is_locked if user else False - - - @classmethod - async def reset_password(cls, session: AsyncSession, user_id: UUID, new_password: str) -> bool: - hashed_password = hash_password(new_password) - user = await cls.get_by_id(session, user_id) - if user: - user.hashed_password = hashed_password - user.failed_login_attempts = 0 # Resetting failed login attempts - user.is_locked = False # Unlocking the user account, if locked - session.add(user) - await session.commit() - return True - return False - - @classmethod - async def verify_email_with_token(cls, session: AsyncSession, user_id: UUID, token: str) -> bool: - user = await cls.get_by_id(session, user_id) - if user and user.verification_token == token: - user.email_verified = True - user.verification_token = None # Clear the token once used - user.role = UserRole.AUTHENTICATED - session.add(user) - await session.commit() - return True - return False - - @classmethod - async def count(cls, session: AsyncSession) -> int: - """ - Count the number of users in the database. - - :param session: The AsyncSession instance for database access. - :return: The count of users. - """ - query = select(func.count()).select_from(User) - result = await session.execute(query) - count = result.scalar() - return count - - @classmethod - async def unlock_user_account(cls, session: AsyncSession, user_id: UUID) -> bool: - user = await cls.get_by_id(session, user_id) - if user and user.is_locked: - user.is_locked = False - user.failed_login_attempts = 0 # Optionally reset failed login attempts - session.add(user) - await session.commit() - return True - return False +from builtins import Exception, bool, classmethod, int, str +from datetime import datetime, timezone +import secrets +from typing import Optional, Dict, List +from pydantic import ValidationError +from sqlalchemy import func, null, update, select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession +from app.dependencies import get_email_service, get_settings +from app.models.user_model import User +from app.schemas.user_schemas import UserCreate, UserUpdate +from app.utils.nickname_gen import generate_nickname +from app.utils.security import generate_verification_token, hash_password, verify_password +from uuid import UUID +from app.services.email_service import EmailService +from app.models.user_model import UserRole +import logging + +settings = get_settings() +logger = logging.getLogger(__name__) + +class UserService: + @classmethod + async def _execute_query(cls, session: AsyncSession, query): + try: + result = await session.execute(query) + await session.commit() + return result + except SQLAlchemyError as e: + logger.error(f"Database error: {e}") + await session.rollback() + return None + + @classmethod + async def _fetch_user(cls, session: AsyncSession, **filters) -> Optional[User]: + query = select(User).filter_by(**filters) + result = await cls._execute_query(session, query) + return result.scalars().first() if result else None + + @classmethod + async def get_by_id(cls, session: AsyncSession, user_id: UUID) -> Optional[User]: + return await cls._fetch_user(session, id=user_id) + + @classmethod + async def get_by_nickname(cls, session: AsyncSession, nickname: str) -> Optional[User]: + return await cls._fetch_user(session, nickname=nickname) + + @classmethod + async def get_by_email(cls, session: AsyncSession, email: str) -> Optional[User]: + return await cls._fetch_user(session, email=email) + + @classmethod + async def create(cls, session: AsyncSession, user_data: Dict[str, str], email_service: EmailService) -> Optional[User]: + try: + validated_data = UserCreate(**user_data).model_dump() + existing_user = await cls.get_by_email(session, validated_data['email']) + if existing_user: + logger.error("User with given email already exists.") + return None + validated_data['hashed_password'] = hash_password(validated_data.pop('password')) + new_user = User(**validated_data) + new_user.verification_token = generate_verification_token() + # new_nickname = generate_nickname() + # while await cls.get_by_nickname(session, new_nickname): + # new_nickname = generate_nickname() + # new_user.nickname = new_nickname + session.add(new_user) + await session.commit() + await email_service.send_verification_email(new_user) + + return new_user + except ValidationError as e: + logger.error(f"Validation error during user creation: {e}") + return None + + @classmethod + async def update(cls, session: AsyncSession, user_id: UUID, update_data: Dict[str, str]) -> Optional[User]: + try: + # validated_data = UserUpdate(**update_data).dict(exclude_unset=True) + validated_data = UserUpdate(**update_data).dict(exclude_unset=True) + + if 'password' in validated_data: + validated_data['hashed_password'] = hash_password(validated_data.pop('password')) + query = update(User).where(User.id == user_id).values(**validated_data).execution_options(synchronize_session="fetch") + await cls._execute_query(session, query) + updated_user = await cls.get_by_id(session, user_id) + if updated_user: + session.refresh(updated_user) # Explicitly refresh the updated user object + logger.info(f"User {user_id} updated successfully.") + return updated_user + else: + logger.error(f"User {user_id} not found after update attempt.") + return None + except Exception as e: # Broad exception handling for debugging + logger.error(f"Error during user update: {e}") + return None + + @classmethod + async def delete(cls, session: AsyncSession, user_id: UUID) -> bool: + user = await cls.get_by_id(session, user_id) + if not user: + logger.info(f"User with ID {user_id} not found.") + return False + await session.delete(user) + await session.commit() + return True + + @classmethod + async def list_users(cls, session: AsyncSession, skip: int = 0, limit: int = 10) -> List[User]: + query = select(User).offset(skip).limit(limit) + result = await cls._execute_query(session, query) + return result.scalars().all() if result else [] + + @classmethod + async def register_user(cls, session: AsyncSession, user_data: Dict[str, str], get_email_service) -> Optional[User]: + return await cls.create(session, user_data, get_email_service) + + + @classmethod + async def login_user(cls, session: AsyncSession, email: str, password: str) -> Optional[User]: + user = await cls.get_by_email(session, email) + if user: + if user.email_verified is False: + return None + if user.is_locked: + return None + if verify_password(password, user.hashed_password): + user.failed_login_attempts = 0 + user.last_login_at = datetime.now(timezone.utc) + session.add(user) + await session.commit() + return user + else: + user.failed_login_attempts += 1 + if user.failed_login_attempts >= settings.max_login_attempts: + user.is_locked = True + session.add(user) + await session.commit() + return None + + @classmethod + async def is_account_locked(cls, session: AsyncSession, email: str) -> bool: + user = await cls.get_by_email(session, email) + return user.is_locked if user else False + + + @classmethod + async def reset_password(cls, session: AsyncSession, user_id: UUID, new_password: str) -> bool: + hashed_password = hash_password(new_password) + user = await cls.get_by_id(session, user_id) + if user: + user.hashed_password = hashed_password + user.failed_login_attempts = 0 # Resetting failed login attempts + user.is_locked = False # Unlocking the user account, if locked + session.add(user) + await session.commit() + return True + return False + + @classmethod + async def verify_email_with_token(cls, session: AsyncSession, user_id: UUID, token: str) -> bool: + user = await cls.get_by_id(session, user_id) + if user and user.verification_token == token: + user.email_verified = True + user.verification_token = None # Clear the token once used + user.role = UserRole.AUTHENTICATED + session.add(user) + await session.commit() + return True + return False + + @classmethod + async def count(cls, session: AsyncSession) -> int: + """ + Count the number of users in the database. + + :param session: The AsyncSession instance for database access. + :return: The count of users. + """ + query = select(func.count()).select_from(User) + result = await session.execute(query) + count = result.scalar() + return count + + @classmethod + async def unlock_user_account(cls, session: AsyncSession, user_id: UUID) -> bool: + user = await cls.get_by_id(session, user_id) + if user and user.is_locked: + user.is_locked = False + user.failed_login_attempts = 0 # Optionally reset failed login attempts + session.add(user) + await session.commit() + return True + return False diff --git a/app/utils/api_description.py b/app/utils/api_description.py index 2cd892456..cb06c3163 100644 --- a/app/utils/api_description.py +++ b/app/utils/api_description.py @@ -1,37 +1,37 @@ -def getDescription(): - description = """ -Application Overview: - -This application is a robust user management system designed to facilitate the administration of user credentials and profiles in a secure and efficient manner. It leverages the capabilities of FastAPI to provide a high-performance, scalable API that adheres to the best practices of modern web service development. - -Key Features: - -- User Authentication: Implements OAuth2 with Password Flow to ensure secure access to the API. Users are required to authenticate using a JWT (JSON Web Token) which provides a secure and efficient means of user identification and authorization. - -- CRUD Operations: Offers comprehensive endpoints for creating, reading, updating, and deleting user information. This includes management of user details such as email, passwords, and personal profiles. - -- Role-Based Access Control: Enforces different access levels using a role-based mechanism that restricts certain operations to users with appropriate privileges. Supported roles include Admin, Manager, and regular Users, each with different permissions. - -- Email Integration: Integrates with email services for account verification and notifications, enhancing the registration and password recovery processes. - -- HATEOAS (Hypermedia as the Engine of Application State): Each response from the API includes hypermedia links to guide the client to other relevant endpoints based on the context of the current interaction, promoting discoverability and ease of navigation within the API. - -- Secure Password Handling: Implements best practices for password security, including hashing and salting techniques, to ensure that user credentials are stored securely. - -- Error Handling: Provides clear and informative error responses that help clients properly handle issues such as authentication failures, access violations, and data conflicts. - -Security Features: - -The application incorporates several security measures to protect data and ensure the integrity and confidentiality of user information: - -- Data Encryption: Uses advanced encryption standards to secure sensitive data in transit and at rest. -- Input Validation: Employs rigorous validation checks to prevent SQL injection, XSS, and other common security threats. -- Rate Limiting: Protects against brute-force attacks by limiting the number of requests a user can make to the API within a given timeframe. - -User Experience: - -Designed with a focus on user experience, the API provides detailed documentation, descriptive error messages, and consistent interface patterns that make it intuitive and straightforward for developers to integrate with their applications. - -This API is ideal for businesses and developers looking for a reliable and secure way to manage user authentication and authorization in their applications. It is particularly suited to environments where security and data privacy are paramount. -""" +def getDescription(): + description = """ +Application Overview: + +This application is a robust user management system designed to facilitate the administration of user credentials and profiles in a secure and efficient manner. It leverages the capabilities of FastAPI to provide a high-performance, scalable API that adheres to the best practices of modern web service development. + +Key Features: + +- User Authentication: Implements OAuth2 with Password Flow to ensure secure access to the API. Users are required to authenticate using a JWT (JSON Web Token) which provides a secure and efficient means of user identification and authorization. + +- CRUD Operations: Offers comprehensive endpoints for creating, reading, updating, and deleting user information. This includes management of user details such as email, passwords, and personal profiles. + +- Role-Based Access Control: Enforces different access levels using a role-based mechanism that restricts certain operations to users with appropriate privileges. Supported roles include Admin, Manager, and regular Users, each with different permissions. + +- Email Integration: Integrates with email services for account verification and notifications, enhancing the registration and password recovery processes. + +- HATEOAS (Hypermedia as the Engine of Application State): Each response from the API includes hypermedia links to guide the client to other relevant endpoints based on the context of the current interaction, promoting discoverability and ease of navigation within the API. + +- Secure Password Handling: Implements best practices for password security, including hashing and salting techniques, to ensure that user credentials are stored securely. + +- Error Handling: Provides clear and informative error responses that help clients properly handle issues such as authentication failures, access violations, and data conflicts. + +Security Features: + +The application incorporates several security measures to protect data and ensure the integrity and confidentiality of user information: + +- Data Encryption: Uses advanced encryption standards to secure sensitive data in transit and at rest. +- Input Validation: Employs rigorous validation checks to prevent SQL injection, XSS, and other common security threats. +- Rate Limiting: Protects against brute-force attacks by limiting the number of requests a user can make to the API within a given timeframe. + +User Experience: + +Designed with a focus on user experience, the API provides detailed documentation, descriptive error messages, and consistent interface patterns that make it intuitive and straightforward for developers to integrate with their applications. + +This API is ideal for businesses and developers looking for a reliable and secure way to manage user authentication and authorization in their applications. It is particularly suited to environments where security and data privacy are paramount. +""" return description \ No newline at end of file diff --git a/app/utils/common.py b/app/utils/common.py index 1281b46bc..82c605771 100644 --- a/app/utils/common.py +++ b/app/utils/common.py @@ -1,16 +1,16 @@ -import logging.config -import os -from app.dependencies import get_settings - -settings = get_settings() -def setup_logging(): - """ - Sets up logging for the application using a configuration file. - This ensures standardized logging across the entire application. - """ - # Construct the path to 'logging.conf', assuming it's in the project's root. - logging_config_path = os.path.join(os.path.dirname(__file__), '..', '..', 'logging.conf') - # Normalize the path to handle any '..' correctly. - normalized_path = os.path.normpath(logging_config_path) - # Apply the logging configuration. +import logging.config +import os +from app.dependencies import get_settings + +settings = get_settings() +def setup_logging(): + """ + Sets up logging for the application using a configuration file. + This ensures standardized logging across the entire application. + """ + # Construct the path to 'logging.conf', assuming it's in the project's root. + logging_config_path = os.path.join(os.path.dirname(__file__), '..', '..', 'logging.conf') + # Normalize the path to handle any '..' correctly. + normalized_path = os.path.normpath(logging_config_path) + # Apply the logging configuration. logging.config.fileConfig(normalized_path, disable_existing_loggers=False) \ No newline at end of file diff --git a/app/utils/link_generation.py b/app/utils/link_generation.py index 1f4ae4756..ff545ef43 100644 --- a/app/utils/link_generation.py +++ b/app/utils/link_generation.py @@ -1,48 +1,48 @@ -from builtins import dict, int, max, str -from typing import List, Callable -from urllib.parse import urlencode -from uuid import UUID - -from fastapi import Request -from app.schemas.link_schema import Link -from app.schemas.pagination_schema import PaginationLink - -# Utility function to create a link -def create_link(rel: str, href: str, method: str = "GET", action: str = None) -> Link: - return Link(rel=rel, href=href, method=method, action=action) - -def create_pagination_link(rel: str, base_url: str, params: dict) -> PaginationLink: - # Ensure parameters are added in a specific order - query_string = f"skip={params['skip']}&limit={params['limit']}" - return PaginationLink(rel=rel, href=f"{base_url}?{query_string}") - -def create_user_links(user_id: UUID, request: Request) -> List[Link]: - """ - Generate navigation links for user actions. - """ - actions = [ - ("self", "get_user", "GET", "view"), - ("update", "update_user", "PUT", "update"), - ("delete", "delete_user", "DELETE", "delete") - ] - return [ - create_link(rel, str(request.url_for(action, user_id=str(user_id))), method, action_desc) - for rel, action, method, action_desc in actions - ] - -def generate_pagination_links(request: Request, skip: int, limit: int, total_items: int) -> List[PaginationLink]: - base_url = str(request.url) - total_pages = (total_items + limit - 1) // limit - links = [ - create_pagination_link("self", base_url, {'skip': skip, 'limit': limit}), - create_pagination_link("first", base_url, {'skip': 0, 'limit': limit}), - create_pagination_link("last", base_url, {'skip': max(0, (total_pages - 1) * limit), 'limit': limit}) - ] - - if skip + limit < total_items: - links.append(create_pagination_link("next", base_url, {'skip': skip + limit, 'limit': limit})) - - if skip > 0: - links.append(create_pagination_link("prev", base_url, {'skip': max(skip - limit, 0), 'limit': limit})) - - return links +from builtins import dict, int, max, str +from typing import List, Callable +from urllib.parse import urlencode +from uuid import UUID + +from fastapi import Request +from app.schemas.link_schema import Link +from app.schemas.pagination_schema import PaginationLink + +# Utility function to create a link +def create_link(rel: str, href: str, method: str = "GET", action: str = None) -> Link: + return Link(rel=rel, href=href, method=method, action=action) + +def create_pagination_link(rel: str, base_url: str, params: dict) -> PaginationLink: + # Ensure parameters are added in a specific order + query_string = f"skip={params['skip']}&limit={params['limit']}" + return PaginationLink(rel=rel, href=f"{base_url}?{query_string}") + +def create_user_links(user_id: UUID, request: Request) -> List[Link]: + """ + Generate navigation links for user actions. + """ + actions = [ + ("self", "get_user", "GET", "view"), + ("update", "update_user", "PUT", "update"), + ("delete", "delete_user", "DELETE", "delete") + ] + return [ + create_link(rel, str(request.url_for(action, user_id=str(user_id))), method, action_desc) + for rel, action, method, action_desc in actions + ] + +def generate_pagination_links(request: Request, skip: int, limit: int, total_items: int) -> List[PaginationLink]: + base_url = str(request.url) + total_pages = (total_items + limit - 1) // limit + links = [ + create_pagination_link("self", base_url, {'skip': skip, 'limit': limit}), + create_pagination_link("first", base_url, {'skip': 0, 'limit': limit}), + create_pagination_link("last", base_url, {'skip': max(0, (total_pages - 1) * limit), 'limit': limit}) + ] + + if skip + limit < total_items: + links.append(create_pagination_link("next", base_url, {'skip': skip + limit, 'limit': limit})) + + if skip > 0: + links.append(create_pagination_link("prev", base_url, {'skip': max(skip - limit, 0), 'limit': limit})) + + return links diff --git a/app/utils/nickname_gen.py b/app/utils/nickname_gen.py index 3491ff96c..a452be371 100644 --- a/app/utils/nickname_gen.py +++ b/app/utils/nickname_gen.py @@ -1,10 +1,10 @@ -from builtins import str -import random - - -def generate_nickname() -> str: - """Generate a URL-safe nickname using adjectives and animal names.""" - adjectives = ["clever", "jolly", "brave", "sly", "gentle"] - animals = ["panda", "fox", "raccoon", "koala", "lion"] - number = random.randint(0, 999) - return f"{random.choice(adjectives)}_{random.choice(animals)}_{number}" +from builtins import str +import random + + +def generate_nickname() -> str: + """Generate a URL-safe nickname using adjectives and animal names.""" + adjectives = ["clever", "jolly", "brave", "sly", "gentle"] + animals = ["panda", "fox", "raccoon", "koala", "lion"] + number = random.randint(0, 999) + return f"{random.choice(adjectives)}_{random.choice(animals)}_{number}" diff --git a/app/utils/security.py b/app/utils/security.py index eceeb8e8a..fab17b098 100644 --- a/app/utils/security.py +++ b/app/utils/security.py @@ -1,53 +1,53 @@ -# app/security.py -from builtins import Exception, ValueError, bool, int, str -import secrets -import bcrypt -from logging import getLogger - -# Set up logging -logger = getLogger(__name__) - -def hash_password(password: str, rounds: int = 12) -> str: - """ - Hashes a password using bcrypt with a specified cost factor. - - Args: - password (str): The plain text password to hash. - rounds (int): The cost factor that determines the computational cost of hashing. - - Returns: - str: The hashed password. - - Raises: - ValueError: If hashing the password fails. - """ - try: - salt = bcrypt.gensalt(rounds=rounds) - hashed_password = bcrypt.hashpw(password.encode('utf-8'), salt) - return hashed_password.decode('utf-8') - except Exception as e: - logger.error("Failed to hash password: %s", e) - raise ValueError("Failed to hash password") from e - -def verify_password(plain_password: str, hashed_password: str) -> bool: - """ - Verifies a plain text password against a hashed password. - - Args: - plain_password (str): The plain text password to verify. - hashed_password (str): The bcrypt hashed password. - - Returns: - bool: True if the password is correct, False otherwise. - - Raises: - ValueError: If the hashed password format is incorrect or the function fails to verify. - """ - try: - return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8')) - except Exception as e: - logger.error("Error verifying password: %s", e) - raise ValueError("Authentication process encountered an unexpected error") from e - -def generate_verification_token(): +# app/security.py +from builtins import Exception, ValueError, bool, int, str +import secrets +import bcrypt +from logging import getLogger + +# Set up logging +logger = getLogger(__name__) + +def hash_password(password: str, rounds: int = 12) -> str: + """ + Hashes a password using bcrypt with a specified cost factor. + + Args: + password (str): The plain text password to hash. + rounds (int): The cost factor that determines the computational cost of hashing. + + Returns: + str: The hashed password. + + Raises: + ValueError: If hashing the password fails. + """ + try: + salt = bcrypt.gensalt(rounds=rounds) + hashed_password = bcrypt.hashpw(password.encode('utf-8'), salt) + return hashed_password.decode('utf-8') + except Exception as e: + logger.error("Failed to hash password: %s", e) + raise ValueError("Failed to hash password") from e + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """ + Verifies a plain text password against a hashed password. + + Args: + plain_password (str): The plain text password to verify. + hashed_password (str): The bcrypt hashed password. + + Returns: + bool: True if the password is correct, False otherwise. + + Raises: + ValueError: If the hashed password format is incorrect or the function fails to verify. + """ + try: + return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8')) + except Exception as e: + logger.error("Error verifying password: %s", e) + raise ValueError("Authentication process encountered an unexpected error") from e + +def generate_verification_token(): return secrets.token_urlsafe(16) # Generates a secure 16-byte URL-safe token \ No newline at end of file diff --git a/app/utils/smtp_connection.py b/app/utils/smtp_connection.py index b13c04a2a..e3b38d1ca 100644 --- a/app/utils/smtp_connection.py +++ b/app/utils/smtp_connection.py @@ -1,31 +1,31 @@ -# smtp_client.py -from builtins import Exception, int, str -import smtplib -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart -from settings.config import settings -import logging - -class SMTPClient: - def __init__(self, server: str, port: int, username: str, password: str): - self.server = server - self.port = port - self.username = username - self.password = password - - def send_email(self, subject: str, html_content: str, recipient: str): - try: - message = MIMEMultipart('alternative') - message['Subject'] = subject - message['From'] = self.username - message['To'] = recipient - message.attach(MIMEText(html_content, 'html')) - - with smtplib.SMTP(self.server, self.port) as server: - server.starttls() # Use TLS - server.login(self.username, self.password) - server.sendmail(self.username, recipient, message.as_string()) - logging.info(f"Email sent to {recipient}") - except Exception as e: - logging.error(f"Failed to send email: {str(e)}") - raise +# smtp_client.py +from builtins import Exception, int, str +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from settings.config import settings +import logging + +class SMTPClient: + def __init__(self, server: str, port: int, username: str, password: str): + self.server = server + self.port = port + self.username = username + self.password = password + + def send_email(self, subject: str, html_content: str, recipient: str): + try: + message = MIMEMultipart('alternative') + message['Subject'] = subject + message['From'] = self.username + message['To'] = recipient + message.attach(MIMEText(html_content, 'html')) + + with smtplib.SMTP(self.server, self.port) as server: + server.starttls() # Use TLS + server.login(self.username, self.password) + server.sendmail(self.username, recipient, message.as_string()) + logging.info(f"Email sent to {recipient}") + except Exception as e: + logging.error(f"Failed to send email: {str(e)}") + raise diff --git a/app/utils/template_manager.py b/app/utils/template_manager.py index f57d239fc..80e36f92d 100644 --- a/app/utils/template_manager.py +++ b/app/utils/template_manager.py @@ -1,46 +1,46 @@ -import markdown2 -from pathlib import Path - -class TemplateManager: - def __init__(self): - # Dynamically determine the root path of the project - self.root_dir = Path(__file__).resolve().parent.parent.parent # Adjust this depending on the structure - self.templates_dir = self.root_dir / 'email_templates' - - def _read_template(self, filename: str) -> str: - """Private method to read template content.""" - template_path = self.templates_dir / filename - with open(template_path, 'r', encoding='utf-8') as file: - return file.read() - - def _apply_email_styles(self, html: str) -> str: - """Apply advanced CSS styles inline for email compatibility with excellent typography.""" - styles = { - 'body': 'font-family: Arial, sans-serif; font-size: 16px; color: #333333; background-color: #ffffff; line-height: 1.5;', - 'h1': 'font-size: 24px; color: #333333; font-weight: bold; margin-top: 20px; margin-bottom: 10px;', - 'p': 'font-size: 16px; color: #666666; margin: 10px 0; line-height: 1.6;', - 'a': 'color: #0056b3; text-decoration: none; font-weight: bold;', - 'footer': 'font-size: 12px; color: #777777; padding: 20px 0;', - 'ul': 'list-style-type: none; padding: 0;', - 'li': 'margin-bottom: 10px;' - } - # Wrap entire HTML content in
with body style - styled_html = f'
{html}
' - # Apply styles to each HTML element - for tag, style in styles.items(): - if tag != 'body': # Skip the body style since it's already applied to the
- styled_html = styled_html.replace(f'<{tag}>', f'<{tag} style="{style}">') - return styled_html - - def render_template(self, template_name: str, **context) -> str: - """Render a markdown template with given context, applying advanced email styles.""" - header = self._read_template('header.md') - footer = self._read_template('footer.md') - - # Read main template and format it with provided context - main_template = self._read_template(f'{template_name}.md') - main_content = main_template.format(**context) - - full_markdown = f"{header}\n{main_content}\n{footer}" - html_content = markdown2.markdown(full_markdown) - return self._apply_email_styles(html_content) +import markdown2 +from pathlib import Path + +class TemplateManager: + def __init__(self): + # Dynamically determine the root path of the project + self.root_dir = Path(__file__).resolve().parent.parent.parent # Adjust this depending on the structure + self.templates_dir = self.root_dir / 'email_templates' + + def _read_template(self, filename: str) -> str: + """Private method to read template content.""" + template_path = self.templates_dir / filename + with open(template_path, 'r', encoding='utf-8') as file: + return file.read() + + def _apply_email_styles(self, html: str) -> str: + """Apply advanced CSS styles inline for email compatibility with excellent typography.""" + styles = { + 'body': 'font-family: Arial, sans-serif; font-size: 16px; color: #333333; background-color: #ffffff; line-height: 1.5;', + 'h1': 'font-size: 24px; color: #333333; font-weight: bold; margin-top: 20px; margin-bottom: 10px;', + 'p': 'font-size: 16px; color: #666666; margin: 10px 0; line-height: 1.6;', + 'a': 'color: #0056b3; text-decoration: none; font-weight: bold;', + 'footer': 'font-size: 12px; color: #777777; padding: 20px 0;', + 'ul': 'list-style-type: none; padding: 0;', + 'li': 'margin-bottom: 10px;' + } + # Wrap entire HTML content in
with body style + styled_html = f'
{html}
' + # Apply styles to each HTML element + for tag, style in styles.items(): + if tag != 'body': # Skip the body style since it's already applied to the
+ styled_html = styled_html.replace(f'<{tag}>', f'<{tag} style="{style}">') + return styled_html + + def render_template(self, template_name: str, **context) -> str: + """Render a markdown template with given context, applying advanced email styles.""" + header = self._read_template('header.md') + footer = self._read_template('footer.md') + + # Read main template and format it with provided context + main_template = self._read_template(f'{template_name}.md') + main_content = main_template.format(**context) + + full_markdown = f"{header}\n{main_content}\n{footer}" + html_content = markdown2.markdown(full_markdown) + return self._apply_email_styles(html_content) diff --git a/docker-compose.yml b/docker-compose.yml index bb4392056..8c69de764 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,60 +1,60 @@ -version: '3.8' - -services: - postgres: - image: postgres:16.2 - environment: - POSTGRES_DB: myappdb - POSTGRES_USER: user - POSTGRES_PASSWORD: password - healthcheck: - test: ["CMD-SHELL", "pg_isready -U user -d myappdb"] - interval: 10s - timeout: 5s - retries: 5 - networks: - - app-network - - pgadmin: - image: dpage/pgadmin4 - environment: - PGADMIN_DEFAULT_EMAIL: admin@example.com - PGADMIN_DEFAULT_PASSWORD: adminpassword - PGADMIN_LISTEN_PORT: 80 - depends_on: - - postgres - ports: - - "5050:80" # Expose PgAdmin on port 5050 of the host - volumes: - - pgadmin-data:/var/lib/pgadmin - networks: - - app-network - - fastapi: - build: . - volumes: - - ./:/myapp/ - depends_on: - postgres: - condition: service_healthy - networks: - - app-network - command: ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"] - - nginx: - image: nginx:latest - ports: - - "80:80" - volumes: - - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf - depends_on: - - fastapi - networks: - - app-network - -volumes: - postgres-data: - pgadmin-data: - -networks: +# version: '3.8' + +services: + postgres: + image: postgres:16.2 + environment: + POSTGRES_DB: myappdb + POSTGRES_USER: user + POSTGRES_PASSWORD: password + healthcheck: + test: ["CMD-SHELL", "pg_isready -U user -d myappdb"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - app-network + + pgadmin: + image: dpage/pgadmin4 + environment: + PGADMIN_DEFAULT_EMAIL: admin@example.com + PGADMIN_DEFAULT_PASSWORD: adminpassword + PGADMIN_LISTEN_PORT: 80 + depends_on: + - postgres + ports: + - "5050:80" # Expose PgAdmin on port 5050 of the host + volumes: + - pgadmin-data:/var/lib/pgadmin + networks: + - app-network + + fastapi: + build: . + volumes: + - ./:/myapp/ + depends_on: + postgres: + condition: service_healthy + networks: + - app-network + command: ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"] + + nginx: + image: nginx:latest + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf + depends_on: + - fastapi + networks: + - app-network + +volumes: + postgres-data: + pgadmin-data: + +networks: app-network: \ No newline at end of file diff --git a/docker.md b/docker.md index b594bbb26..fccdb8d73 100644 --- a/docker.md +++ b/docker.md @@ -1,98 +1,98 @@ -# Comprehensive Docker Compose Guide for Students - -## Overview -This guide will walk you through the process of using Docker Compose to manage a multi-container application consisting of a PostgreSQL database, PgAdmin for database management, a FastAPI application, and Nginx as a reverse proxy. - -## Prerequisites -- Ensure Docker and Docker Compose are installed on your computer. -- Basic understanding of Docker, PostgreSQL, FastAPI, and how web applications work. - -## Docker Compose Setup - -### Starting the Services -- To start all services defined in the Docker Compose file, navigate to the directory containing your `docker-compose.yml` file and run: - - **`docker-compose up -d`** - - This command starts the containers in the background. - -### Accessing PgAdmin -- Open your web browser and visit `http://localhost:5050` to access PgAdmin. -- Login with the following credentials: - - **Email**: `admin@example.com` - - **Password**: `adminpassword` -- Configure the PostgreSQL server in PgAdmin: - - **Right-click** on 'Servers' in the left pane and select **'Create' > 'Server'**. - - In the 'General' tab, give your server a name (e.g., `MyAppDB`). - - Switch to the 'Connection' tab and enter: - - **Hostname/address**: `postgres` - - **Port**: `5432` - - **Username**: `user` - - **Password**: `password` - - These credentials correspond to the environment variables set in the `docker-compose.yml` for the PostgreSQL service. - -## Managing Application Data with Docker - -### Running Database Migrations -- Execute database migrations within the FastAPI container: - - **`docker-compose exec fastapi alembic upgrade head`** - - This command runs the Alembic upgrade command to apply migrations to your PostgreSQL database. - -### Running Tests with Pytest -- To run tests inside the FastAPI container, ensuring they interact with the PostgreSQL service: - - **`docker-compose exec fastapi pytest`** - - This command runs all tests defined in your FastAPI application. - -### Specific Test Execution -- To run a specific test file: - - **`docker-compose exec fastapi pytest /myapp/tests/test_specific_file.py`** - -### Running Tests with Coverage -- For executing tests with coverage reports: - - **`docker-compose exec fastapi pytest --cov=myapp`** - - To generate an HTML coverage report: - - **`docker-compose exec fastapi pytest --cov=myapp --cov-report=html`** - -## Resetting the Testing Environment -- If you need to reset your environment, e.g., to clear test data: - - **Stop all services and remove volumes**: - - **`docker-compose down -v`** - - **Restart the services**: - - **`docker-compose up -d`** - -## Docker Basics - -### Building Docker Images -- To build a Docker image for your FastAPI application, ensure you have a Dockerfile in the same directory as your `docker-compose.yml`. Then run: - - **`docker-compose build`** - -## Pushing Images to Docker Hub - -### Creating a Docker Hub Account -- Visit [Docker Hub](https://hub.docker.com/) and sign up for an account. -- Once registered, you can create repositories to store your Docker images. - -### Logging into Docker Hub from the Command Line -- **`docker login`** -- Enter your Docker Hub username and password. - -### Tagging Your Docker Image -- **`docker tag local-image:tagname username/repository:tag`** - - For example: - - **`docker tag myfastapi:latest john/myfastapi:latest`** - -### Pushing the Image -- **`docker push username/repository:tag`** - - For example: - - **`docker push john/myfastapi:latest`** - -## Additional Tips - -### Viewing Logs -- To view logs for troubleshooting or monitoring application behavior: - - **`docker-compose logs -f`** - - The `-f` flag tails the log output. - -### Shutting Down -- To stop and remove all running containers: - - **`docker-compose down`** - -This guide is structured to provide clear, step-by-step instructions on how to interact with the Dockerized environment defined by your Docker Compose setup, ideal for educational purposes and ensuring students are well-equipped to manage their development environment effectively. +# Comprehensive Docker Compose Guide for Students + +## Overview +This guide will walk you through the process of using Docker Compose to manage a multi-container application consisting of a PostgreSQL database, PgAdmin for database management, a FastAPI application, and Nginx as a reverse proxy. + +## Prerequisites +- Ensure Docker and Docker Compose are installed on your computer. +- Basic understanding of Docker, PostgreSQL, FastAPI, and how web applications work. + +## Docker Compose Setup + +### Starting the Services +- To start all services defined in the Docker Compose file, navigate to the directory containing your `docker-compose.yml` file and run: + - **`docker-compose up -d`** + - This command starts the containers in the background. + +### Accessing PgAdmin +- Open your web browser and visit `http://localhost:5050` to access PgAdmin. +- Login with the following credentials: + - **Email**: `admin@example.com` + - **Password**: `adminpassword` +- Configure the PostgreSQL server in PgAdmin: + - **Right-click** on 'Servers' in the left pane and select **'Create' > 'Server'**. + - In the 'General' tab, give your server a name (e.g., `MyAppDB`). + - Switch to the 'Connection' tab and enter: + - **Hostname/address**: `postgres` + - **Port**: `5432` + - **Username**: `user` + - **Password**: `password` + - These credentials correspond to the environment variables set in the `docker-compose.yml` for the PostgreSQL service. + +## Managing Application Data with Docker + +### Running Database Migrations +- Execute database migrations within the FastAPI container: + - **`docker-compose exec fastapi alembic upgrade head`** + - This command runs the Alembic upgrade command to apply migrations to your PostgreSQL database. + +### Running Tests with Pytest +- To run tests inside the FastAPI container, ensuring they interact with the PostgreSQL service: + - **`docker-compose exec fastapi pytest`** + - This command runs all tests defined in your FastAPI application. + +### Specific Test Execution +- To run a specific test file: + - **`docker-compose exec fastapi pytest /myapp/tests/test_specific_file.py`** + +### Running Tests with Coverage +- For executing tests with coverage reports: + - **`docker-compose exec fastapi pytest --cov=myapp`** + - To generate an HTML coverage report: + - **`docker-compose exec fastapi pytest --cov=myapp --cov-report=html`** + +## Resetting the Testing Environment +- If you need to reset your environment, e.g., to clear test data: + - **Stop all services and remove volumes**: + - **`docker-compose down -v`** + - **Restart the services**: + - **`docker-compose up -d`** + +## Docker Basics + +### Building Docker Images +- To build a Docker image for your FastAPI application, ensure you have a Dockerfile in the same directory as your `docker-compose.yml`. Then run: + - **`docker-compose build`** + +## Pushing Images to Docker Hub + +### Creating a Docker Hub Account +- Visit [Docker Hub](https://hub.docker.com/) and sign up for an account. +- Once registered, you can create repositories to store your Docker images. + +### Logging into Docker Hub from the Command Line +- **`docker login`** +- Enter your Docker Hub username and password. + +### Tagging Your Docker Image +- **`docker tag local-image:tagname username/repository:tag`** + - For example: + - **`docker tag myfastapi:latest john/myfastapi:latest`** + +### Pushing the Image +- **`docker push username/repository:tag`** + - For example: + - **`docker push john/myfastapi:latest`** + +## Additional Tips + +### Viewing Logs +- To view logs for troubleshooting or monitoring application behavior: + - **`docker-compose logs -f`** + - The `-f` flag tails the log output. + +### Shutting Down +- To stop and remove all running containers: + - **`docker-compose down`** + +This guide is structured to provide clear, step-by-step instructions on how to interact with the Dockerized environment defined by your Docker Compose setup, ideal for educational purposes and ensuring students are well-equipped to manage their development environment effectively. diff --git a/email_templates/email_verification.md b/email_templates/email_verification.md index 042f923e8..7e80ded16 100644 --- a/email_templates/email_verification.md +++ b/email_templates/email_verification.md @@ -1,8 +1,8 @@ -Hello {name}, - -Thank you for registering at OurSite. Please click the following link to verify your email address: - -[Verify Email]({verification_url}) - -Thanks, -The OurSite Team +Hello {name}, + +Thank you for registering at OurSite. Please click the following link to verify your email address: + +[Verify Email]({verification_url}) + +Thanks, +The OurSite Team diff --git a/email_templates/footer.md b/email_templates/footer.md index 3ff6d440d..cebf95c67 100644 --- a/email_templates/footer.md +++ b/email_templates/footer.md @@ -1,13 +1,13 @@ -Sincerely, -[Your Company Name] -[Your Company Address] -[City, State, Zip] - -You are receiving this email because you have opted in at our website. If you no longer wish to receive these emails, you can unsubscribe at any time by clicking [here](#). - -Company Registration Number: [Company Registration Number] -VAT Number: [VAT Number] - -This email and any files transmitted with it are confidential and intended solely for the use of the individual or entity to whom they are addressed. If you have received this email in error, please notify the system manager. This message contains confidential information and is intended only for the individual named. If you are not the named addressee you should not disseminate, distribute or copy this e-mail. - -Please consider the environment before printing this email. +Sincerely, +[Your Company Name] +[Your Company Address] +[City, State, Zip] + +You are receiving this email because you have opted in at our website. If you no longer wish to receive these emails, you can unsubscribe at any time by clicking [here](#). + +Company Registration Number: [Company Registration Number] +VAT Number: [VAT Number] + +This email and any files transmitted with it are confidential and intended solely for the use of the individual or entity to whom they are addressed. If you have received this email in error, please notify the system manager. This message contains confidential information and is intended only for the individual named. If you are not the named addressee you should not disseminate, distribute or copy this e-mail. + +Please consider the environment before printing this email. diff --git a/email_templates/header.md b/email_templates/header.md index d64f8deb7..5e2c87b48 100644 --- a/email_templates/header.md +++ b/email_templates/header.md @@ -1,6 +1,6 @@ -![Your Company Logo](http://example.com/path/to/your/logo.png) - -# Welcome to [Your Company Name] - ---- - +![Your Company Logo](http://example.com/path/to/your/logo.png) + +# Welcome to [Your Company Name] + +--- + diff --git a/email_templates/test_email.md b/email_templates/test_email.md index 8916a59f8..3ec1156e8 100644 --- a/email_templates/test_email.md +++ b/email_templates/test_email.md @@ -1,9 +1,9 @@ -# Hello, Email - -This is a *simple* test email sent from a Markdown file. - -- Point 1 -- Point 2 -- Point 3 - -**Thank you!** +# Hello, Email + +This is a *simple* test email sent from a Markdown file. + +- Point 1 +- Point 2 +- Point 3 + +**Thank you!** diff --git a/git.md b/git.md index d8ac97c2f..f8742c568 100644 --- a/git.md +++ b/git.md @@ -1,138 +1,138 @@ -# Project Management Manual - -## Overview -This manual provides detailed instructions and commands for managing the development environment and workflow using Git, Docker, and pytest. It is tailored for a project that uses a Docker Compose setup involving multiple services including PostgreSQL, PGAdmin, FastAPI, and Nginx. - -## Git Commands - -### Basic Operations - -#### Clone the repository: - - git clone - -#### Create a new branch: - - git checkout -b - -#### Switch to an existing branch: - - git checkout - -#### Check the status of changes: - - git status - -#### Add changes to the staging area: - - git add . - -#### Commit changes: - - git commit -m "Commit message" - -#### Push changes to the remote repository: - - git push origin - -#### Pull changes from the remote repository: - - git pull origin - -### Advanced Branch Management - - -#### List all branches, local and remote: - - git branch -a - -#### Merge another branch into your current branch: - - git merge - -#### Delete a local branch: - - git branch -d - -#### Delete a remote branch: - - git push origin --delete - -#### Stash changes in a dirty working directory: - - git stash - -#### Apply stashed changes back to your working directory: - - git stash pop - -## Working with GitHub Issues - -#### Link a commit to an issue: - - git commit -m "Fixes #123 - commit message" - -#### Close an issue via commit message: - - git commit -m "Closes #123 - commit message" - -## GitFlow Workflow - -GitFlow is a branching model for Git, designed around the project release. This workflow defines a strict branching model designed around the project release. Here’s how it typically works: - -- Master/Main Branch: Stores the official release history. -- Develop Branch: Serves as an integration branch for features. -- Feature Branches: Each new feature should reside in its own branch, which can be pushed to the GitHub repository for backups/collaboration. Feature branches use develop as their parent branch. When a feature is complete, it gets merged back into develop. -- Release Branches: Once develop has acquired enough features for a release (or a predetermined release date is nearing), you fork a release branch off of develop. -- Hotfix Branches: Maintenance or “hotfix” branches are used to quickly patch production releases. Hotfix branches are a lot like release branches and feature branches except they're based on master/main instead of develop. - - -In collaborative environments, especially on platforms like GitHub, GitFlow provides a robust framework where multiple developers can work on various features independently without disrupting the main codebase. Testing should be integral at various stages: - -- Feature Testing: Each feature branch should be thoroughly tested before it is merged back into develop. -- Release Testing: Before a release is finalized, comprehensive testing should be conducted to ensure all integrated features work well together. -- Post-release: Hotfix branches allow quick patches to be applied to production, ensuring any issues that slip through are addressed swiftly. - -This workflow supports continuous integration and deployment practices by allowing for regular merges from development to production branches, with testing checkpoints at crucial stages. - - -### Example GitFlow Commands - -#### Starting development on a new feature: - - git checkout -b feature/ develop - -#### Finishing a feature branch: - - git checkout develop - git merge feature/ --no-ff - git branch -d feature/ - git push origin develop - -#### Preparing a release: - - git checkout -b release/ develop - -#### Make necessary adjustments in the release branch - - git commit -m "Final changes for release " - -#### Completing a release: - - git checkout master - git merge release/ --no-ff - git tag -a - git push origin master - -#### Hotfix branch: - - git checkout -b hotfix/ master - -#### Fix issues - - git commit -m "Fixed " - git checkout master - git merge hotfix/ --no-ff - git tag -a - git push origin master - +# Project Management Manual + +## Overview +This manual provides detailed instructions and commands for managing the development environment and workflow using Git, Docker, and pytest. It is tailored for a project that uses a Docker Compose setup involving multiple services including PostgreSQL, PGAdmin, FastAPI, and Nginx. + +## Git Commands + +### Basic Operations + +#### Clone the repository: + + git clone + +#### Create a new branch: + + git checkout -b + +#### Switch to an existing branch: + + git checkout + +#### Check the status of changes: + + git status + +#### Add changes to the staging area: + + git add . + +#### Commit changes: + + git commit -m "Commit message" + +#### Push changes to the remote repository: + + git push origin + +#### Pull changes from the remote repository: + + git pull origin + +### Advanced Branch Management + + +#### List all branches, local and remote: + + git branch -a + +#### Merge another branch into your current branch: + + git merge + +#### Delete a local branch: + + git branch -d + +#### Delete a remote branch: + + git push origin --delete + +#### Stash changes in a dirty working directory: + + git stash + +#### Apply stashed changes back to your working directory: + + git stash pop + +## Working with GitHub Issues + +#### Link a commit to an issue: + + git commit -m "Fixes #123 - commit message" + +#### Close an issue via commit message: + + git commit -m "Closes #123 - commit message" + +## GitFlow Workflow + +GitFlow is a branching model for Git, designed around the project release. This workflow defines a strict branching model designed around the project release. Here’s how it typically works: + +- Master/Main Branch: Stores the official release history. +- Develop Branch: Serves as an integration branch for features. +- Feature Branches: Each new feature should reside in its own branch, which can be pushed to the GitHub repository for backups/collaboration. Feature branches use develop as their parent branch. When a feature is complete, it gets merged back into develop. +- Release Branches: Once develop has acquired enough features for a release (or a predetermined release date is nearing), you fork a release branch off of develop. +- Hotfix Branches: Maintenance or “hotfix” branches are used to quickly patch production releases. Hotfix branches are a lot like release branches and feature branches except they're based on master/main instead of develop. + + +In collaborative environments, especially on platforms like GitHub, GitFlow provides a robust framework where multiple developers can work on various features independently without disrupting the main codebase. Testing should be integral at various stages: + +- Feature Testing: Each feature branch should be thoroughly tested before it is merged back into develop. +- Release Testing: Before a release is finalized, comprehensive testing should be conducted to ensure all integrated features work well together. +- Post-release: Hotfix branches allow quick patches to be applied to production, ensuring any issues that slip through are addressed swiftly. + +This workflow supports continuous integration and deployment practices by allowing for regular merges from development to production branches, with testing checkpoints at crucial stages. + + +### Example GitFlow Commands + +#### Starting development on a new feature: + + git checkout -b feature/ develop + +#### Finishing a feature branch: + + git checkout develop + git merge feature/ --no-ff + git branch -d feature/ + git push origin develop + +#### Preparing a release: + + git checkout -b release/ develop + +#### Make necessary adjustments in the release branch + + git commit -m "Final changes for release " + +#### Completing a release: + + git checkout master + git merge release/ --no-ff + git tag -a + git push origin master + +#### Hotfix branch: + + git checkout -b hotfix/ master + +#### Fix issues + + git commit -m "Fixed " + git checkout master + git merge hotfix/ --no-ff + git tag -a + git push origin master + diff --git a/license.txt b/license.txt index ab6bd63ff..61f32171f 100644 --- a/license.txt +++ b/license.txt @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) [year] [full name] - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) [year] [full name] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/logging.conf b/logging.conf index 82ff096ae..686dc67e8 100644 --- a/logging.conf +++ b/logging.conf @@ -1,22 +1,22 @@ -[loggers] -keys=root - -[handlers] -keys=consoleHandler - -[formatters] -keys=detailedFormatter - -[logger_root] -level=INFO -handlers=consoleHandler - -[handler_consoleHandler] -class=StreamHandler -level=DEBUG -formatter=detailedFormatter -args=(sys.stdout,) - -[formatter_detailedFormatter] -format=%(asctime)s - %(name)s - %(levelname)s - %(message)s -datefmt=%Y-%m-%d %H:%M:%S +[loggers] +keys=root + +[handlers] +keys=consoleHandler + +[formatters] +keys=detailedFormatter + +[logger_root] +level=INFO +handlers=consoleHandler + +[handler_consoleHandler] +class=StreamHandler +level=DEBUG +formatter=detailedFormatter +args=(sys.stdout,) + +[formatter_detailedFormatter] +format=%(asctime)s - %(name)s - %(levelname)s - %(message)s +datefmt=%Y-%m-%d %H:%M:%S diff --git a/nginx/nginx.conf b/nginx/nginx.conf index cca5cd0e8..ab5e96d77 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -1,11 +1,11 @@ -server { - listen 80; - - location / { - proxy_pass http://fastapi:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} +server { + listen 80; + + location / { + proxy_pass http://fastapi:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/pytest.ini b/pytest.ini index 4851d4948..6b9a43549 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,24 +1,24 @@ -# pytest.ini -[pytest] -testpaths = tests -addopts = -v -python_files = test_*.py *_test.py -python_classes = Test* -python_functions = test_* -asyncio_mode = auto -markers = - slow: marks tests as slow (deselect with '-m "not slow"') - fast: marks tests as fast (deselect with '-m "not fast"') -# log_cli=true -# log_cli_level=DEBUG -# Suppresses specific known warnings or globally ignores certain categories of warnings -filterwarnings = - ignore::DeprecationWarning - ignore::UserWarning - ignore::RuntimeWarning - # Ignore specific warnings from libraries - ignore:the imp module is deprecated in favour of importlib:DeprecationWarning - ignore:Using or importing the ABCs from 'collections':DeprecationWarning - -# Customize logging level if needed -# log_level = INFO +# pytest.ini +[pytest] +testpaths = tests +addopts = -v +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* +asyncio_mode = auto +markers = + slow: marks tests as slow (deselect with '-m "not slow"') + fast: marks tests as fast (deselect with '-m "not fast"') +# log_cli=true +# log_cli_level=DEBUG +# Suppresses specific known warnings or globally ignores certain categories of warnings +filterwarnings = + ignore::DeprecationWarning + ignore::UserWarning + ignore::RuntimeWarning + # Ignore specific warnings from libraries + ignore:the imp module is deprecated in favour of importlib:DeprecationWarning + ignore:Using or importing the ABCs from 'collections':DeprecationWarning + +# Customize logging level if needed +# log_level = INFO diff --git a/readme.md b/readme.md index 9b0d27151..d61720726 100644 --- a/readme.md +++ b/readme.md @@ -1,170 +1,201 @@ -# Event Manager Company: Software QA Analyst/Developer Onboarding Assignment - -Welcome to the Event Manager Company! As a newly hired Software QA Analyst/Developer and a student in software engineering, you are embarking on an exciting journey to contribute to our project aimed at developing a secure, robust REST API that supports JWT (JSON Web Token ) token-based OAuth2 authentication. This API serves as the backbone of our user management system and will eventually expand to include features for event management and registration. - -## Instructor Videos - - - [Introduction to REST API with Postgres](https://youtu.be/dgMCSND2FQw) - This video provides an overview of the REST API you'll be working with, including its structure, endpoints, and interaction with the PostgreSQL database. - - [Assignment Instructions](https://youtu.be/TFblm7QrF6o) - Detailed instructions on your tasks, guiding you through the assignment step by step. - -# Commands - -1. Start and build a multi-container application: - -``` -docker compose up --build -``` - -2. Goto http://localhost/docs to view openapi spec documentation - -Click "authorize" input username: `admin@example.com` password: `secret` - -3. Goto http://localhost:5050 to connect and manage the database. - -The following information must match the ones in the `docker-compose.yml` file. - -Login: - -- Email address / Username: `admin@example.com` -- Password: `adminpassword` - -When add new server: - -- Host name/address: `postgres` -- Port: `5432` -- Maintenance database: `myappdb` -- Username: `user` -- Password: `password` - -## Optional Commands - -### Run `pytest` inside the containers: - -Run all tests: - -``` -docker compose exec fastapi pytest -``` - -Run a single test: - -``` -docker compose exec fastapi pytest tests/test_services/test_user_service.py::test_list_users -``` - -### Creating database migration: - -``` -docker compose exec fastapi alembic revision --autogenerate -m 'added admin' -``` - - -### Apply database migrations: - -``` -docker compose exec fastapi alembic upgrade head -``` - -## Assignment Objectives - -1. **Familiarize with REST API functionality and structure**: Gain hands-on experience working with a REST API, understanding its endpoints, request/response formats, and authentication mechanisms. - -2. **Implement and refine documentation**: Critically analyze and improve existing documentation based on issues identified in the instructor videos. Ensure that the documentation is up-to-date and accurately reflects the current state of the software. - -3. **Engage in manual and automated testing**: Develop comprehensive test cases and leverage automated testing tools like pytest to push the project's test coverage towards 90%. Gain experience with different types of testing, such as unit testing, integration testing, and end-to-end testing. - -4. **Explore and debug issues**: Dive deep into the codebase to investigate and resolve issues related to user profile updates and OAuth token generation. Utilize debugging tools, interpret error messages, and trace the flow of execution to identify the root cause of problems. - -5. **Collaborate effectively**: Experience the power of collaboration using Git for version control and GitHub for code reviews and issue tracking. Work with issues, branches, create pull requests, and merge code while following best practices. - -## Setup and Preliminary Steps - -1. **Fork the Project Repository**: Fork the [project repository](https://github.com/woffee/event_manager) to your own GitHub account. This creates a copy of the repository under your account, allowing you to work on the project independently. - -2. **Clone the Forked Repository**: Clone the forked repository to your local machine using the `git clone` command. This creates a local copy of the repository on your computer, enabling you to make changes and run the project locally. - -3. **Verify the Project Setup**: Follow the steps in the instructor video to set up the project using [Docker](https://www.docker.com/). Docker allows you to package the application with all its dependencies into a standardized unit called a container. Verify that you can access the API documentation at [http://localhost/docs](http://localhost/docs) and the database using [PGAdmin](https://www.pgadmin.org/) at [http://localhost:5050](http://localhost:5050). - -## Testing and Database Management - -1. **Explore the API**: Use [http://localhost/docs](http://localhost/docs) to familiarize yourself with the API endpoints, request/response formats, and authentication mechanisms. It provides an interactive interface to explore and test the API endpoints. - -2. **Run Tests**: Execute the provided test suite using pytest, a popular testing framework for Python. Running tests ensures that the existing functionality of the API is working as expected. Note that running tests will drop the database tables, so you may need to manually drop the Alembic version table using PGAdmin and re-run migrations to ensure a clean state. - -3. **Increase Test Coverage**: To enhance the reliability of the API, aim to increase the project's test coverage to 90%. Write additional tests for various scenarios and edge cases to ensure that the API handles different situations correctly. - -## Collaborative Development Using Git - -1. **Enable Issue Tracking**: Enable GitHub issues in your repository settings. [GitHub Issues](https://guides.github.com/features/issues/) is a powerful tool for tracking bugs, enhancements, and other tasks related to the project. It allows you to create, assign, and prioritize issues, facilitating effective collaboration among team members. - -2. **Create Branches**: For each issue or task you work on, create a new branch with a descriptive name using the `git checkout -b` command. Branching allows you to work on different features or fixes independently without affecting the main codebase. It enables parallel development and helps maintain a stable main branch. - -3. **Pull Requests and Code Reviews**: When you have completed work on an issue, create a [pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests) to merge your changes into the main branch. Pull requests provide an opportunity for code review, where your team members can examine your changes, provide feedback, and suggest improvements. Code reviews help maintain code quality, catch potential issues, and promote knowledge sharing among the team. - -## Specific Issues to Address - -In this assignment, you will identify, document, and resolve five specific issues related to: - -1. **Username validation**: Investigate and resolve any issues related to username validation. This may involve handling special characters, enforcing length constraints, or ensuring uniqueness. Proper username validation is essential to maintain data integrity and prevent potential security vulnerabilities. - -2. **Password validation**: Ensure that password validation follows security best practices, such as enforcing minimum length, requiring complexity (e.g., a mix of uppercase, lowercase, numbers, and special characters), and properly hashing passwords before storing them in the database. Robust password validation protects user accounts and mitigates the risk of unauthorized access. - -3. **Profile field edge cases**: Test and handle various scenarios related to updating profile fields. This may include updating the bio and profile picture URL simultaneously or individually. Consider different combinations of fields being updated and ensure that the API handles these cases gracefully. Edge case testing helps uncover potential issues and ensures a smooth user experience. - -Additionally, you will resolve a sixth issue demonstrated in the instructor video. These issues will test various combinations and scenarios to simulate real-world usage and potential edge cases. By addressing these specific issues, you will gain experience in identifying and resolving common challenges in API development. - -## Submission Requirements - -To complete this assignment, submit the following: - -1. **GitHub Repository Link**: Ensure that your repository is well-organized and includes: - - Five closed issues, each with accompanying test code and necessary application code modifications. - - Each issue should be well-documented, explaining the problem, the steps taken to resolve it, and the outcome. Proper documentation helps others understand your work and facilitates future maintenance. - - All issues should be merged into the main branch, following the Git workflow and best practices. - -2. **Updated README**: Replace the existing README with: - - Links to the closed issues, providing easy access to your work. - - Link to project image deployed to Dockerhub. - - A 2-3 paragraph reflection on what you learned from this assignment, focusing on both technical skills and collaborative processes. Reflect on the challenges you faced, the solutions you implemented, and the insights you gained. This reflection helps solidify your learning and provides valuable feedback for improving the assignment in the future. - -## Grading Rubric - -| Criteria | Points | -|-------------------------------------------------------------------------------------------------------------------------|--------| -| Resolved 5 issues related to username validation, password validation, and profile field edge cases | 30 | -| Resolved the issue demonstrated in the instructor video | 20 | -| Increased test coverage to 90% by writing comprehensive test cases | 20 | -| Followed collaborative development practices using Git and GitHub (branching, pull requests, code reviews) | 15 | -| Submitted a well-organized GitHub repository with clear documentation, links to closed issues, and a reflective summary | 15 | -| **Total** | **100**| - -## Resources and Documentation - -- **Important Links**: - - [Git Command Reference I created and some explanation for collaboration with git](git.md) - - [Docker Commands and Running The Tests in the Application](docker.md) - - Look at the code comments: - - [Test Configuration and Fixtures](tests/conftest.py) - - [API User Routes](app/routers/user_routes.py) - - [API Oauth Routes - Connection to HTTP](app/routers/oauth.py) - - [User Service - Business Logic - This implements whats called the service repository pattern](app/services/user_service.py) - - [User Schema - Pydantic models](app/schemas/user_schemas.py) - - [User Model - SQl Alchemy Model ](app/models/user_model.py) - - [Alembic Migration - this is what runs to create the tables when you do alembic upgrade head](alembic/versions/628adcb2d60e_initial_migration.py) - - See the tests folder for all the tests - - - API Documentation: [http://localhost/docs](http://localhost/docs) - Provides information on endpoints, request/response formats, and authentication. - - Database Management: [http://localhost:5050](http://localhost:5050) - The PGAdmin interface for managing the PostgreSQL database, allowing you to view and manipulate the database tables. - - Email service: [https://mailtrap.io/](https://mailtrap.io/) - Email Delivery Platform that delivers just in time. Great for dev, and marketing teams. After registered, put the credentials in `.env` file. - -- **Code Documentation**: - The project codebase includes docstrings and comments explaining various concepts and functionalities. Take the time to read through the code and understand how different components work together. Pay attention to the structure of the code, the naming conventions used, and the purpose of each function or class. Understanding the existing codebase will help you write code that is consistent and integrates well with the project. - -- **Additional Resources**: - - [SQLAlchemy Library](https://www.sqlalchemy.org/) - SQLAlchemy is a powerful SQL toolkit and Object-Relational Mapping (ORM) library for Python. It provides a set of tools for interacting with databases, including query building, database schema management, and data serialization. Familiarize yourself with SQLAlchemy's documentation to understand how it is used in the project for database operations. - - [Pydantic Documentation](https://docs.pydantic.dev/latest/) - Pydantic is a data validation and settings management library for Python. It allows you to define data models with type annotations and provides automatic validation, serialization, and deserialization. Consult the Pydantic documentation to understand how it is used in the project for request/response validation and serialization. - - [FastAPI Framework](https://fastapi.tiangolo.com/) - FastAPI is a modern, fast (high-performance) Python web framework for building APIs. It leverages Python's type hints and provides automatic API documentation, request/response validation, and easy integration with other libraries. Explore the FastAPI documentation to gain a deeper understanding of its features and how it is used in the project. - - [Alembic Documentation](https://alembic.sqlalchemy.org/en/latest/index.html) - Alembic is a lightweight database migration tool for usage with SQLAlchemy. It allows you to define and manage database schema changes over time, ensuring that the database structure remains consistent across different environments. Refer to the Alembic documentation to learn how to create and apply database migrations in the project. - -These resources will provide you with a solid foundation to understand the tools, technologies, and concepts used in the project. Don't hesitate to explore them further and consult the documentation whenever you encounter challenges or need clarification. - +# Event Manager Company: Software QA Analyst/Developer Onboarding Assignment + +Welcome to the Event Manager Company! As a newly hired Software QA Analyst/Developer and a student in software engineering, you are embarking on an exciting journey to contribute to our project aimed at developing a secure, robust REST API that supports JWT (JSON Web Token ) token-based OAuth2 authentication. This API serves as the backbone of our user management system and will eventually expand to include features for event management and registration. + +## Instructor Videos + + - [Introduction to REST API with Postgres](https://youtu.be/dgMCSND2FQw) - This video provides an overview of the REST API you'll be working with, including its structure, endpoints, and interaction with the PostgreSQL database. + - [Assignment Instructions](https://youtu.be/TFblm7QrF6o) - Detailed instructions on your tasks, guiding you through the assignment step by step. + +# Commands + +1. Start and build a multi-container application: + +``` +docker compose up --build +``` + +2. Goto http://localhost/docs to view openapi spec documentation + +Click "authorize" input username: `admin@example.com` password: `secret` + +3. Goto http://localhost:5050 to connect and manage the database. + +The following information must match the ones in the `docker-compose.yml` file. + +Login: + +- Email address / Username: `admin@example.com` +- Password: `adminpassword` + +When add new server: + +- Host name/address: `postgres` +- Port: `5432` +- Maintenance database: `myappdb` +- Username: `user` +- Password: `password` + +## Optional Commands + +### Run `pytest` inside the containers: + +Run all tests: + +``` +docker compose exec fastapi pytest +``` + +Run a single test: + +``` +docker compose exec fastapi pytest tests/test_services/test_user_service.py::test_list_users +``` + +### Creating database migration: + +``` +docker compose exec fastapi alembic revision --autogenerate -m 'added admin' +``` + + +### Apply database migrations: + +``` +docker compose exec fastapi alembic upgrade head +``` + +## Assignment Objectives + +1. **Familiarize with REST API functionality and structure**: Gain hands-on experience working with a REST API, understanding its endpoints, request/response formats, and authentication mechanisms. + +2. **Implement and refine documentation**: Critically analyze and improve existing documentation based on issues identified in the instructor videos. Ensure that the documentation is up-to-date and accurately reflects the current state of the software. + +3. **Engage in manual and automated testing**: Develop comprehensive test cases and leverage automated testing tools like pytest to push the project's test coverage towards 90%. Gain experience with different types of testing, such as unit testing, integration testing, and end-to-end testing. + +4. **Explore and debug issues**: Dive deep into the codebase to investigate and resolve issues related to user profile updates and OAuth token generation. Utilize debugging tools, interpret error messages, and trace the flow of execution to identify the root cause of problems. + +5. **Collaborate effectively**: Experience the power of collaboration using Git for version control and GitHub for code reviews and issue tracking. Work with issues, branches, create pull requests, and merge code while following best practices. + +## Setup and Preliminary Steps + +1. **Fork the Project Repository**: Fork the [project repository](https://github.com/woffee/event_manager) to your own GitHub account. This creates a copy of the repository under your account, allowing you to work on the project independently. + +2. **Clone the Forked Repository**: Clone the forked repository to your local machine using the `git clone` command. This creates a local copy of the repository on your computer, enabling you to make changes and run the project locally. + +3. **Verify the Project Setup**: Follow the steps in the instructor video to set up the project using [Docker](https://www.docker.com/). Docker allows you to package the application with all its dependencies into a standardized unit called a container. Verify that you can access the API documentation at [http://localhost/docs](http://localhost/docs) and the database using [PGAdmin](https://www.pgadmin.org/) at [http://localhost:5050](http://localhost:5050). + +## Testing and Database Management + +1. **Explore the API**: Use [http://localhost/docs](http://localhost/docs) to familiarize yourself with the API endpoints, request/response formats, and authentication mechanisms. It provides an interactive interface to explore and test the API endpoints. + +2. **Run Tests**: Execute the provided test suite using pytest, a popular testing framework for Python. Running tests ensures that the existing functionality of the API is working as expected. Note that running tests will drop the database tables, so you may need to manually drop the Alembic version table using PGAdmin and re-run migrations to ensure a clean state. + +3. **Increase Test Coverage**: To enhance the reliability of the API, aim to increase the project's test coverage to 90%. Write additional tests for various scenarios and edge cases to ensure that the API handles different situations correctly. + +## Collaborative Development Using Git + +1. **Enable Issue Tracking**: Enable GitHub issues in your repository settings. [GitHub Issues](https://guides.github.com/features/issues/) is a powerful tool for tracking bugs, enhancements, and other tasks related to the project. It allows you to create, assign, and prioritize issues, facilitating effective collaboration among team members. + +2. **Create Branches**: For each issue or task you work on, create a new branch with a descriptive name using the `git checkout -b` command. Branching allows you to work on different features or fixes independently without affecting the main codebase. It enables parallel development and helps maintain a stable main branch. + +3. **Pull Requests and Code Reviews**: When you have completed work on an issue, create a [pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests) to merge your changes into the main branch. Pull requests provide an opportunity for code review, where your team members can examine your changes, provide feedback, and suggest improvements. Code reviews help maintain code quality, catch potential issues, and promote knowledge sharing among the team. + +## Specific Issues to Address + +In this assignment, you will identify, document, and resolve five specific issues related to: + +1. **Username validation**: Investigate and resolve any issues related to username validation. This may involve handling special characters, enforcing length constraints, or ensuring uniqueness. Proper username validation is essential to maintain data integrity and prevent potential security vulnerabilities. + +2. **Password validation**: Ensure that password validation follows security best practices, such as enforcing minimum length, requiring complexity (e.g., a mix of uppercase, lowercase, numbers, and special characters), and properly hashing passwords before storing them in the database. Robust password validation protects user accounts and mitigates the risk of unauthorized access. + +3. **Profile field edge cases**: Test and handle various scenarios related to updating profile fields. This may include updating the bio and profile picture URL simultaneously or individually. Consider different combinations of fields being updated and ensure that the API handles these cases gracefully. Edge case testing helps uncover potential issues and ensures a smooth user experience. + +Additionally, you will resolve a sixth issue demonstrated in the instructor video. These issues will test various combinations and scenarios to simulate real-world usage and potential edge cases. By addressing these specific issues, you will gain experience in identifying and resolving common challenges in API development. + +## Submission Requirements + +### CLOSED ISSUES ### + +1. **Issues in docker compose preventing succesful application build** + https://github.com/AlexandersWrld/eventmanager/issues/1 + +2. **Nickname is user registration does not match output** + https://github.com/AlexandersWrld/eventmanager/issues/2 + +3. **User log in test assertation unsucceesful** + https://github.com/AlexandersWrld/eventmanager/issues/3 + +4. **KeyError in User Schema Test** + https://github.com/AlexandersWrld/eventmanager/issues/7 + +5. **Error in user update test - KeyError (first_name)** + https://github.com/AlexandersWrld/eventmanager/issues/9 + +6. **Error in user login with incorrect password test** + https://github.com/AlexandersWrld/eventmanager/issues/11 + +## Reflection ## +This assignment helped me get a better understanding of what it's like to work with a larger-scale project in a collaberative environment. I understand now the real purpose behind forking code from a repository, to create my own local copy of the repo. At first when I just started the assignment I was making pull requests to the main repo rather than to my fork. It took me two tries to get it right but I eventually caught the hang of the correct workflow. Through the creation of issues and through branching, I understand better now how to make clean changes without affecting the main repo. +I came to realize that in the case of making pull requests, that I would not be authorized to pull directly to the main repo, and that my requests were more mere suggesttions. This has given me some idea of what it might be like working with a team as a junior analyst. I'd imagine the senior analysts would be the ones approving my changes. + + +## Project Image on Docker ## + +![alt text]() + + +## Instructions ## +To complete this assignment, submit the following: + +1. **GitHub Repository Link**: Ensure that your repository is well-organized and includes: + - Five closed issues, each with accompanying test code and necessary application code modifications. + - Each issue should be well-documented, explaining the problem, the steps taken to resolve it, and the outcome. Proper documentation helps others understand your work and facilitates future maintenance. + - All issues should be merged into the main branch, following the Git workflow and best practices. + +2. **Updated README**: Replace the existing README with: + - Links to the closed issues, providing easy access to your work. + - Link to project image deployed to Dockerhub. + - A 2-3 paragraph reflection on what you learned from this assignment, focusing on both technical skills and collaborative processes. Reflect on the challenges you faced, the solutions you implemented, and the insights you gained. This reflection helps solidify your learning and provides valuable feedback for improving the assignment in the future. + +## Grading Rubric + +| Criteria | Points | +|-------------------------------------------------------------------------------------------------------------------------|--------| +| Resolved 5 issues related to username validation, password validation, and profile field edge cases | 30 | +| Resolved the issue demonstrated in the instructor video | 20 | +| Increased test coverage to 90% by writing comprehensive test cases | 20 | +| Followed collaborative development practices using Git and GitHub (branching, pull requests, code reviews) | 15 | +| Submitted a well-organized GitHub repository with clear documentation, links to closed issues, and a reflective summary | 15 | +| **Total** | **100**| + +## Resources and Documentation + +- **Important Links**: + - [Git Command Reference I created and some explanation for collaboration with git](git.md) + - [Docker Commands and Running The Tests in the Application](docker.md) + - Look at the code comments: + - [Test Configuration and Fixtures](tests/conftest.py) + - [API User Routes](app/routers/user_routes.py) + - [API Oauth Routes - Connection to HTTP](app/routers/oauth.py) + - [User Service - Business Logic - This implements whats called the service repository pattern](app/services/user_service.py) + - [User Schema - Pydantic models](app/schemas/user_schemas.py) + - [User Model - SQl Alchemy Model ](app/models/user_model.py) + - [Alembic Migration - this is what runs to create the tables when you do alembic upgrade head](alembic/versions/628adcb2d60e_initial_migration.py) + - See the tests folder for all the tests + + - API Documentation: [http://localhost/docs](http://localhost/docs) - Provides information on endpoints, request/response formats, and authentication. + - Database Management: [http://localhost:5050](http://localhost:5050) - The PGAdmin interface for managing the PostgreSQL database, allowing you to view and manipulate the database tables. + - Email service: [https://mailtrap.io/](https://mailtrap.io/) - Email Delivery Platform that delivers just in time. Great for dev, and marketing teams. After registered, put the credentials in `.env` file. + +- **Code Documentation**: + The project codebase includes docstrings and comments explaining various concepts and functionalities. Take the time to read through the code and understand how different components work together. Pay attention to the structure of the code, the naming conventions used, and the purpose of each function or class. Understanding the existing codebase will help you write code that is consistent and integrates well with the project. + +- **Additional Resources**: + - [SQLAlchemy Library](https://www.sqlalchemy.org/) - SQLAlchemy is a powerful SQL toolkit and Object-Relational Mapping (ORM) library for Python. It provides a set of tools for interacting with databases, including query building, database schema management, and data serialization. Familiarize yourself with SQLAlchemy's documentation to understand how it is used in the project for database operations. + - [Pydantic Documentation](https://docs.pydantic.dev/latest/) - Pydantic is a data validation and settings management library for Python. It allows you to define data models with type annotations and provides automatic validation, serialization, and deserialization. Consult the Pydantic documentation to understand how it is used in the project for request/response validation and serialization. + - [FastAPI Framework](https://fastapi.tiangolo.com/) - FastAPI is a modern, fast (high-performance) Python web framework for building APIs. It leverages Python's type hints and provides automatic API documentation, request/response validation, and easy integration with other libraries. Explore the FastAPI documentation to gain a deeper understanding of its features and how it is used in the project. + - [Alembic Documentation](https://alembic.sqlalchemy.org/en/latest/index.html) - Alembic is a lightweight database migration tool for usage with SQLAlchemy. It allows you to define and manage database schema changes over time, ensuring that the database structure remains consistent across different environments. Refer to the Alembic documentation to learn how to create and apply database migrations in the project. + +These resources will provide you with a solid foundation to understand the tools, technologies, and concepts used in the project. Don't hesitate to explore them further and consult the documentation whenever you encounter challenges or need clarification. + diff --git a/requirements.txt b/requirements.txt index 4306e8ed1..398665d18 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,63 +1,63 @@ -aiofiles==23.2.1 -aiomysql==0.2.0 -alembic==1.13.1 -annotated-types==0.6.0 -anyio==4.3.0 -async-sqlalchemy==1.0.0 -async-timeout==4.0.3 -asyncio==3.4.3 -asyncpg==0.29.0 -bcrypt==4.1.2 -certifi==2024.2.2 -cffi==1.16.0 -click==8.1.7 -coverage==7.4.4 -cryptography==42.0.5 -dnspython==2.6.1 -ecdsa==0.18.0 -email_validator==2.1.1 -exceptiongroup==1.2.0 -factory-boy==3.3.0 -Faker==24.4.0 -fastapi==0.110.0 -greenlet==3.0.3 -gunicorn==21.2.0 -h11==0.14.0 -httpcore==1.0.5 -httpx==0.27.0 -idna==3.6 -iniconfig==2.0.0 -Mako==1.3.2 -MarkupSafe==2.1.5 -packaging==24.0 -passlib==1.7.4 -pluggy==1.4.0 -psycopg==3.1.18 -psycopg2-binary==2.9.9 -pyasn1==0.6.0 -pycparser==2.22 -pydantic==2.6.4 -pydantic-settings==2.2.1 -pydantic_core==2.16.3 -PyMySQL==1.1.0 -pypng==0.20220715.0 -pytest==8.1.1 -pytest-asyncio==0.23.6 -pytest-cov==5.0.0 -pytest-mock==3.14.0 -python-dateutil==2.9.0.post0 -python-dotenv==1.0.1 -python-jose==3.3.0 -python-multipart==0.0.9 -qrcode==7.4.2 -rsa==4.9 -six==1.16.0 -sniffio==1.3.1 -SQLAlchemy==2.0.29 -starlette==0.36.3 -tomli==2.0.1 -typing_extensions==4.10.0 -uvicorn==0.29.0 -validators==0.24.0 -markdown2 +aiofiles==23.2.1 +aiomysql==0.2.0 +alembic==1.13.1 +annotated-types==0.6.0 +anyio==4.3.0 +async-sqlalchemy==1.0.0 +async-timeout==4.0.3 +asyncio==3.4.3 +asyncpg==0.29.0 +bcrypt==4.1.2 +certifi==2024.2.2 +cffi==1.16.0 +click==8.1.7 +coverage==7.4.4 +cryptography==42.0.5 +dnspython==2.6.1 +ecdsa==0.18.0 +email_validator==2.1.1 +exceptiongroup==1.2.0 +factory-boy==3.3.0 +Faker==24.4.0 +fastapi==0.110.0 +greenlet==3.0.3 +gunicorn==21.2.0 +h11==0.14.0 +httpcore==1.0.5 +httpx==0.27.0 +idna==3.6 +iniconfig==2.0.0 +Mako==1.3.2 +MarkupSafe==2.1.5 +packaging==24.0 +passlib==1.7.4 +pluggy==1.4.0 +psycopg==3.1.18 +psycopg2-binary==2.9.9 +pyasn1==0.6.0 +pycparser==2.22 +pydantic==2.6.4 +pydantic-settings==2.2.1 +pydantic_core==2.16.3 +PyMySQL==1.1.0 +pypng==0.20220715.0 +pytest==8.1.1 +pytest-asyncio==0.23.6 +pytest-cov==5.0.0 +pytest-mock==3.14.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +python-jose==3.3.0 +python-multipart==0.0.9 +qrcode==7.4.2 +rsa==4.9 +six==1.16.0 +sniffio==1.3.1 +SQLAlchemy==2.0.29 +starlette==0.36.3 +tomli==2.0.1 +typing_extensions==4.10.0 +uvicorn==0.29.0 +validators==0.24.0 +markdown2 pyjwt \ No newline at end of file diff --git a/settings/config.py b/settings/config.py index 5b5cf8771..3827feb66 100644 --- a/settings/config.py +++ b/settings/config.py @@ -1,51 +1,51 @@ -from builtins import bool, int, str -from pathlib import Path -from pydantic import Field, AnyUrl, DirectoryPath -from pydantic_settings import BaseSettings - -class Settings(BaseSettings): - max_login_attempts: int = Field(default=3, description="Background color of QR codes") - # Server configuration - server_base_url: AnyUrl = Field(default='http://localhost', description="Base URL of the server") - server_download_folder: str = Field(default='downloads', description="Folder for storing downloaded files") - - # Security and authentication configuration - secret_key: str = Field(default="secret-key", description="Secret key for encryption") - algorithm: str = Field(default="HS256", description="Algorithm used for encryption") - access_token_expire_minutes: int = Field(default=30, description="Expiration time for access tokens in minutes") - admin_user: str = Field(default='admin', description="Default admin username") - admin_password: str = Field(default='secret', description="Default admin password") - debug: bool = Field(default=False, description="Debug mode outputs errors and sqlalchemy queries") - jwt_secret_key: str = "a_very_secret_key" - jwt_algorithm: str = "HS256" - access_token_expire_minutes: int = 15 # 15 minutes for access token - refresh_token_expire_minutes: int = 1440 # 24 hours for refresh token - # Database configuration - database_url: str = Field(default='postgresql+asyncpg://user:password@postgres/myappdb', description="URL for connecting to the database") - - # Optional: If preferring to construct the SQLAlchemy database URL from components - postgres_user: str = Field(default='user', description="PostgreSQL username") - postgres_password: str = Field(default='password', description="PostgreSQL password") - postgres_server: str = Field(default='localhost', description="PostgreSQL server address") - postgres_port: str = Field(default='5432', description="PostgreSQL port") - postgres_db: str = Field(default='myappdb', description="PostgreSQL database name") - # Discord configuration - discord_bot_token: str = Field(default='NONE', description="Discord bot token") - discord_channel_id: int = Field(default=1234567890, description="Default Discord channel ID for the bot to interact", example=1234567890) - #Open AI Key - openai_api_key: str = Field(default='NONE', description="Open AI Api Key") - send_real_mail: bool = Field(default=False, description="use mock") - # Email settings for Mailtrap - smtp_server: str = Field(default='smtp.mailtrap.io', description="SMTP server for sending emails") - smtp_port: int = Field(default=2525, description="SMTP port for sending emails") - smtp_username: str = Field(default='your-mailtrap-username', description="Username for SMTP server") - smtp_password: str = Field(default='your-mailtrap-password', description="Password for SMTP server") - - - class Config: - # If your .env file is not in the root directory, adjust the path accordingly. - env_file = ".env" - env_file_encoding = 'utf-8' - -# Instantiate settings to be imported in your application -settings = Settings() +from builtins import bool, int, str +from pathlib import Path +from pydantic import Field, AnyUrl, DirectoryPath +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + max_login_attempts: int = Field(default=3, description="Background color of QR codes") + # Server configuration + server_base_url: AnyUrl = Field(default='http://localhost', description="Base URL of the server") + server_download_folder: str = Field(default='downloads', description="Folder for storing downloaded files") + + # Security and authentication configuration + secret_key: str = Field(default="secret-key", description="Secret key for encryption") + algorithm: str = Field(default="HS256", description="Algorithm used for encryption") + access_token_expire_minutes: int = Field(default=30, description="Expiration time for access tokens in minutes") + admin_user: str = Field(default='admin', description="Default admin username") + admin_password: str = Field(default='secret', description="Default admin password") + debug: bool = Field(default=False, description="Debug mode outputs errors and sqlalchemy queries") + jwt_secret_key: str = "a_very_secret_key" + jwt_algorithm: str = "HS256" + access_token_expire_minutes: int = 15 # 15 minutes for access token + refresh_token_expire_minutes: int = 1440 # 24 hours for refresh token + # Database configuration + database_url: str = Field(default='postgresql+asyncpg://user:password@postgres/myappdb', description="URL for connecting to the database") + + # Optional: If preferring to construct the SQLAlchemy database URL from components + postgres_user: str = Field(default='user', description="PostgreSQL username") + postgres_password: str = Field(default='password', description="PostgreSQL password") + postgres_server: str = Field(default='localhost', description="PostgreSQL server address") + postgres_port: str = Field(default='5432', description="PostgreSQL port") + postgres_db: str = Field(default='myappdb', description="PostgreSQL database name") + # Discord configuration + discord_bot_token: str = Field(default='NONE', description="Discord bot token") + discord_channel_id: int = Field(default=1234567890, description="Default Discord channel ID for the bot to interact", example=1234567890) + #Open AI Key + openai_api_key: str = Field(default='NONE', description="Open AI Api Key") + send_real_mail: bool = Field(default=False, description="use mock") + # Email settings for Mailtrap + smtp_server: str = Field(default='smtp.mailtrap.io', description="SMTP server for sending emails") + smtp_port: int = Field(default=2525, description="SMTP port for sending emails") + smtp_username: str = Field(default='your-mailtrap-username', description="Username for SMTP server") + smtp_password: str = Field(default='your-mailtrap-password', description="Password for SMTP server") + + + class Config: + # If your .env file is not in the root directory, adjust the path accordingly. + env_file = ".env" + env_file_encoding = 'utf-8' + +# Instantiate settings to be imported in your application +settings = Settings() diff --git a/tests/conftest.py b/tests/conftest.py index 6bd7998ac..71e5b3fd5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,263 +1,265 @@ -""" -File: test_database_operations.py - -Overview: -This Python test file utilizes pytest to manage database states and HTTP clients for testing a web application built with FastAPI and SQLAlchemy. It includes detailed fixtures to mock the testing environment, ensuring each test is run in isolation with a consistent setup. - -Fixtures: -- `async_client`: Manages an asynchronous HTTP client for testing interactions with the FastAPI application. -- `db_session`: Handles database transactions to ensure a clean database state for each test. -- User fixtures (`user`, `locked_user`, `verified_user`, etc.): Set up various user states to test different behaviors under diverse conditions. -- `token`: Generates an authentication token for testing secured endpoints. -- `initialize_database`: Prepares the database at the session start. -- `setup_database`: Sets up and tears down the database before and after each test. -""" - -# Standard library imports -from builtins import range -from datetime import datetime -from unittest.mock import patch -from uuid import uuid4 - -# Third-party imports -import pytest -from fastapi.testclient import TestClient -from httpx import AsyncClient -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession -from sqlalchemy.orm import sessionmaker, scoped_session -from faker import Faker - -# Application-specific imports -from app.main import app -from app.database import Base, Database -from app.models.user_model import User, UserRole -from app.dependencies import get_db, get_settings -from app.utils.security import hash_password -from app.utils.template_manager import TemplateManager -from app.services.email_service import EmailService -from app.services.jwt_service import create_access_token - -fake = Faker() - -settings = get_settings() -TEST_DATABASE_URL = settings.database_url.replace("postgresql://", "postgresql+asyncpg://") -engine = create_async_engine(TEST_DATABASE_URL, echo=settings.debug) -AsyncTestingSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) -AsyncSessionScoped = scoped_session(AsyncTestingSessionLocal) - - -@pytest.fixture -def email_service(): - # Assuming the TemplateManager does not need any arguments for initialization - template_manager = TemplateManager() - email_service = EmailService(template_manager=template_manager) - return email_service - - -# this is what creates the http client for your api tests -@pytest.fixture(scope="function") -async def async_client(db_session): - async with AsyncClient(app=app, base_url="http://testserver") as client: - app.dependency_overrides[get_db] = lambda: db_session - try: - yield client - finally: - app.dependency_overrides.clear() - -@pytest.fixture(scope="session", autouse=True) -def initialize_database(): - try: - Database.initialize(settings.database_url) - except Exception as e: - pytest.fail(f"Failed to initialize the database: {str(e)}") - -# this function setup and tears down (drops tales) for each test function, so you have a clean database for each test. -@pytest.fixture(scope="function", autouse=True) -async def setup_database(): - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - yield - async with engine.begin() as conn: - # you can comment out this line during development if you are debugging a single test - await conn.run_sync(Base.metadata.drop_all) - await engine.dispose() - -@pytest.fixture(scope="function") -async def db_session(setup_database): - async with AsyncSessionScoped() as session: - try: - yield session - finally: - await session.close() - -@pytest.fixture(scope="function") -async def locked_user(db_session): - unique_email = fake.email() - user_data = { - "nickname": fake.user_name(), - "first_name": fake.first_name(), - "last_name": fake.last_name(), - "email": unique_email, - "hashed_password": hash_password("MySuperPassword$1234"), - "role": UserRole.AUTHENTICATED, - "email_verified": False, - "is_locked": True, - "failed_login_attempts": settings.max_login_attempts, - } - user = User(**user_data) - db_session.add(user) - await db_session.commit() - return user - -@pytest.fixture(scope="function") -async def user(db_session): - user_data = { - "nickname": fake.user_name(), - "first_name": fake.first_name(), - "last_name": fake.last_name(), - "email": fake.email(), - "hashed_password": hash_password("MySuperPassword$1234"), - "role": UserRole.AUTHENTICATED, - "email_verified": False, - "is_locked": False, - } - user = User(**user_data) - db_session.add(user) - await db_session.commit() - return user - -@pytest.fixture(scope="function") -async def verified_user(db_session): - user_data = { - "nickname": fake.user_name(), - "first_name": fake.first_name(), - "last_name": fake.last_name(), - "email": fake.email(), - "hashed_password": hash_password("MySuperPassword$1234"), - "role": UserRole.AUTHENTICATED, - "email_verified": True, - "is_locked": False, - } - user = User(**user_data) - db_session.add(user) - await db_session.commit() - return user - -@pytest.fixture(scope="function") -async def unverified_user(db_session): - user_data = { - "nickname": fake.user_name(), - "first_name": fake.first_name(), - "last_name": fake.last_name(), - "email": fake.email(), - "hashed_password": hash_password("MySuperPassword$1234"), - "role": UserRole.AUTHENTICATED, - "email_verified": False, - "is_locked": False, - } - user = User(**user_data) - db_session.add(user) - await db_session.commit() - return user - -@pytest.fixture(scope="function") -async def users_with_same_role_50_users(db_session): - users = [] - for _ in range(50): - user_data = { - "nickname": fake.user_name(), - "first_name": fake.first_name(), - "last_name": fake.last_name(), - "email": fake.email(), - "hashed_password": fake.password(), - "role": UserRole.AUTHENTICATED, - "email_verified": False, - "is_locked": False, - } - user = User(**user_data) - db_session.add(user) - users.append(user) - await db_session.commit() - return users - -@pytest.fixture -async def admin_user(db_session: AsyncSession): - user = User( - nickname="admin_user", - email="admin@example.com", - first_name="John", - last_name="Doe", - hashed_password="securepassword", - role=UserRole.ADMIN, - is_locked=False, - ) - db_session.add(user) - await db_session.commit() - return user - -@pytest.fixture -async def manager_user(db_session: AsyncSession): - user = User( - nickname="manager_john", - first_name="John", - last_name="Doe", - email="manager_user@example.com", - hashed_password="securepassword", - role=UserRole.MANAGER, - is_locked=False, - ) - db_session.add(user) - await db_session.commit() - return user - - -# Fixtures for common test data -@pytest.fixture -def user_base_data(): - return { - "username": "john_doe_123", - "email": "john.doe@example.com", - "full_name": "John Doe", - "bio": "I am a software engineer with over 5 years of experience.", - "profile_picture_url": "https://example.com/profile_pictures/john_doe.jpg" - } - -@pytest.fixture -def user_base_data_invalid(): - return { - "username": "john_doe_123", - "email": "john.doe.example.com", - "full_name": "John Doe", - "bio": "I am a software engineer with over 5 years of experience.", - "profile_picture_url": "https://example.com/profile_pictures/john_doe.jpg" - } - - -@pytest.fixture -def user_create_data(user_base_data): - return {**user_base_data, "password": "SecurePassword123!"} - -@pytest.fixture -def user_update_data(): - return { - "email": "john.doe.new@example.com", - "full_name": "John H. Doe", - "bio": "I specialize in backend development with Python and Node.js.", - "profile_picture_url": "https://example.com/profile_pictures/john_doe_updated.jpg" - } - -@pytest.fixture -def user_response_data(): - return { - "id": "unique-id-string", - "username": "testuser", - "email": "test@example.com", - "last_login_at": datetime.now(), - "created_at": datetime.now(), - "updated_at": datetime.now(), - "links": [] - } - -@pytest.fixture -def login_request_data(): +""" +File: test_database_operations.py + +Overview: +This Python test file utilizes pytest to manage database states and HTTP clients for testing a web application built with FastAPI and SQLAlchemy. It includes detailed fixtures to mock the testing environment, ensuring each test is run in isolation with a consistent setup. + +Fixtures: +- `async_client`: Manages an asynchronous HTTP client for testing interactions with the FastAPI application. +- `db_session`: Handles database transactions to ensure a clean database state for each test. +- User fixtures (`user`, `locked_user`, `verified_user`, etc.): Set up various user states to test different behaviors under diverse conditions. +- `token`: Generates an authentication token for testing secured endpoints. +- `initialize_database`: Prepares the database at the session start. +- `setup_database`: Sets up and tears down the database before and after each test. +""" + +# Standard library imports +from builtins import range +from datetime import datetime +from unittest.mock import patch +from uuid import uuid4 + +# Third-party imports +import pytest +from fastapi.testclient import TestClient +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker, scoped_session +from faker import Faker + +# Application-specific imports +from app.main import app +from app.database import Base, Database +from app.models.user_model import User, UserRole +from app.dependencies import get_db, get_settings +from app.utils.security import hash_password +from app.utils.template_manager import TemplateManager +from app.services.email_service import EmailService +from app.services.jwt_service import create_access_token + +fake = Faker() + +settings = get_settings() +TEST_DATABASE_URL = settings.database_url.replace("postgresql://", "postgresql+asyncpg://") +engine = create_async_engine(TEST_DATABASE_URL, echo=settings.debug) +AsyncTestingSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) +AsyncSessionScoped = scoped_session(AsyncTestingSessionLocal) + + +@pytest.fixture +def email_service(): + # Assuming the TemplateManager does not need any arguments for initialization + template_manager = TemplateManager() + email_service = EmailService(template_manager=template_manager) + return email_service + + +# this is what creates the http client for your api tests +@pytest.fixture(scope="function") +async def async_client(db_session): + async with AsyncClient(app=app, base_url="http://testserver") as client: + app.dependency_overrides[get_db] = lambda: db_session + try: + yield client + finally: + app.dependency_overrides.clear() + +@pytest.fixture(scope="session", autouse=True) +def initialize_database(): + try: + Database.initialize(settings.database_url) + except Exception as e: + pytest.fail(f"Failed to initialize the database: {str(e)}") + +# this function setup and tears down (drops tales) for each test function, so you have a clean database for each test. +@pytest.fixture(scope="function", autouse=True) +async def setup_database(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with engine.begin() as conn: + # you can comment out this line during development if you are debugging a single test + await conn.run_sync(Base.metadata.drop_all) + await engine.dispose() + +@pytest.fixture(scope="function") +async def db_session(setup_database): + async with AsyncSessionScoped() as session: + try: + yield session + finally: + await session.close() + +@pytest.fixture(scope="function") +async def locked_user(db_session): + unique_email = fake.email() + user_data = { + "nickname": fake.user_name(), + "first_name": fake.first_name(), + "last_name": fake.last_name(), + "email": unique_email, + "hashed_password": hash_password("MySuperPassword$1234"), + "role": UserRole.AUTHENTICATED, + "email_verified": False, + "is_locked": True, + "failed_login_attempts": settings.max_login_attempts, + } + user = User(**user_data) + db_session.add(user) + await db_session.commit() + return user + +@pytest.fixture(scope="function") +async def user(db_session): + user_data = { + "nickname": fake.user_name(), + "first_name": fake.first_name(), + "last_name": fake.last_name(), + "email": fake.email(), + "hashed_password": hash_password("MySuperPassword$1234"), + "role": UserRole.AUTHENTICATED, + "email_verified": False, + "is_locked": False, + } + user = User(**user_data) + db_session.add(user) + await db_session.commit() + return user + +@pytest.fixture(scope="function") +async def verified_user(db_session): + user_data = { + "nickname": fake.user_name(), + "first_name": fake.first_name(), + "last_name": fake.last_name(), + "email": fake.email(), + "hashed_password": hash_password("MySuperPassword$1234"), + "role": UserRole.AUTHENTICATED, + "email_verified": True, + "is_locked": False, + } + user = User(**user_data) + db_session.add(user) + await db_session.commit() + return user + +@pytest.fixture(scope="function") +async def unverified_user(db_session): + user_data = { + "nickname": fake.user_name(), + "first_name": fake.first_name(), + "last_name": fake.last_name(), + "email": fake.email(), + "hashed_password": hash_password("MySuperPassword$1234"), + "role": UserRole.AUTHENTICATED, + "email_verified": False, + "is_locked": False, + } + user = User(**user_data) + db_session.add(user) + await db_session.commit() + return user + +@pytest.fixture(scope="function") +async def users_with_same_role_50_users(db_session): + users = [] + for _ in range(50): + user_data = { + "nickname": fake.user_name(), + "first_name": fake.first_name(), + "last_name": fake.last_name(), + "email": fake.email(), + "hashed_password": fake.password(), + "role": UserRole.AUTHENTICATED, + "email_verified": False, + "is_locked": False, + } + user = User(**user_data) + db_session.add(user) + users.append(user) + await db_session.commit() + return users + +@pytest.fixture +async def admin_user(db_session: AsyncSession): + user = User( + nickname="admin_user", + email="admin@example.com", + first_name="John", + last_name="Doe", + hashed_password="securepassword", + role=UserRole.ADMIN, + is_locked=False, + ) + db_session.add(user) + await db_session.commit() + return user + +@pytest.fixture +async def manager_user(db_session: AsyncSession): + user = User( + nickname="manager_john", + first_name="John", + last_name="Doe", + email="manager_user@example.com", + hashed_password="securepassword", + role=UserRole.MANAGER, + is_locked=False, + ) + db_session.add(user) + await db_session.commit() + return user + + +# Fixtures for common test data +@pytest.fixture +def user_base_data(): + return { + "username": "john_doe_123", + "nickname": "john_doe", + "email": "john.doe@example.com", + "full_name": "John Doe", + "bio": "I am a software engineer with over 5 years of experience.", + "profile_picture_url": "https://example.com/profile_pictures/john_doe.jpg" + } + +@pytest.fixture +def user_base_data_invalid(): + return { + "username": "john_doe_123", + "email": "john.doe.example.com", + "full_name": "John Doe", + "bio": "I am a software engineer with over 5 years of experience.", + "profile_picture_url": "https://example.com/profile_pictures/john_doe.jpg" + } + + +@pytest.fixture +def user_create_data(user_base_data): + return {**user_base_data, "password": "SecurePassword123!"} + +@pytest.fixture +def user_update_data(): + return { + "email": "john.doe.new@example.com", + "full_name": "John H. Doe", + "first_name": "John", + "bio": "I specialize in backend development with Python and Node.js.", + "profile_picture_url": "https://example.com/profile_pictures/john_doe_updated.jpg" + } + +@pytest.fixture +def user_response_data(): + return { + "id": "unique-id-string", + "username": "testuser", + "email": "test@example.com", + "last_login_at": datetime.now(), + "created_at": datetime.now(), + "updated_at": datetime.now(), + "links": [] + } + +@pytest.fixture +def login_request_data(): return {"username": "john_doe_123", "password": "SecurePassword123!"} \ No newline at end of file diff --git a/tests/test_api/test_users_api.py b/tests/test_api/test_users_api.py index 7b497e1bd..968ffc73d 100644 --- a/tests/test_api/test_users_api.py +++ b/tests/test_api/test_users_api.py @@ -1,191 +1,192 @@ -from builtins import str -import pytest -from httpx import AsyncClient -from app.main import app -from app.models.user_model import User -from app.utils.nickname_gen import generate_nickname -from app.utils.security import hash_password -from app.services.jwt_service import decode_token # Import your FastAPI app - -# Example of a test function using the async_client fixture -@pytest.mark.asyncio -async def test_create_user_access_denied(async_client, user_token, email_service): - headers = {"Authorization": f"Bearer {user_token}"} - # Define user data for the test - user_data = { - "nickname": generate_nickname(), - "email": "test@example.com", - "password": "sS#fdasrongPassword123!", - } - # Send a POST request to create a user - response = await async_client.post("/users/", json=user_data, headers=headers) - # Asserts - assert response.status_code == 403 - -# You can similarly refactor other test functions to use the async_client fixture -@pytest.mark.asyncio -async def test_retrieve_user_access_denied(async_client, verified_user, user_token): - headers = {"Authorization": f"Bearer {user_token}"} - response = await async_client.get(f"/users/{verified_user.id}", headers=headers) - assert response.status_code == 403 - -@pytest.mark.asyncio -async def test_retrieve_user_access_allowed(async_client, admin_user, admin_token): - headers = {"Authorization": f"Bearer {admin_token}"} - response = await async_client.get(f"/users/{admin_user.id}", headers=headers) - assert response.status_code == 200 - assert response.json()["id"] == str(admin_user.id) - -@pytest.mark.asyncio -async def test_update_user_email_access_denied(async_client, verified_user, user_token): - updated_data = {"email": f"updated_{verified_user.id}@example.com"} - headers = {"Authorization": f"Bearer {user_token}"} - response = await async_client.put(f"/users/{verified_user.id}", json=updated_data, headers=headers) - assert response.status_code == 403 - -@pytest.mark.asyncio -async def test_update_user_email_access_allowed(async_client, admin_user, admin_token): - updated_data = {"email": f"updated_{admin_user.id}@example.com"} - headers = {"Authorization": f"Bearer {admin_token}"} - response = await async_client.put(f"/users/{admin_user.id}", json=updated_data, headers=headers) - assert response.status_code == 200 - assert response.json()["email"] == updated_data["email"] - - -@pytest.mark.asyncio -async def test_delete_user(async_client, admin_user, admin_token): - headers = {"Authorization": f"Bearer {admin_token}"} - delete_response = await async_client.delete(f"/users/{admin_user.id}", headers=headers) - assert delete_response.status_code == 204 - # Verify the user is deleted - fetch_response = await async_client.get(f"/users/{admin_user.id}", headers=headers) - assert fetch_response.status_code == 404 - -@pytest.mark.asyncio -async def test_create_user_duplicate_email(async_client, verified_user): - user_data = { - "email": verified_user.email, - "password": "AnotherPassword123!", - } - response = await async_client.post("/register/", json=user_data) - assert response.status_code == 400 - assert "Email already exists" in response.json().get("detail", "") - -@pytest.mark.asyncio -async def test_create_user_invalid_email(async_client): - user_data = { - "email": "notanemail", - "password": "ValidPassword123!", - } - response = await async_client.post("/register/", json=user_data) - assert response.status_code == 422 - -import pytest -from app.services.jwt_service import decode_token -from urllib.parse import urlencode - -@pytest.mark.asyncio -async def test_login_success(async_client, verified_user): - # Attempt to login with the test user - form_data = { - "username": verified_user.email, - "password": "MySuperPassword$1234" - } - response = await async_client.post("/login/", data=urlencode(form_data), headers={"Content-Type": "application/x-www-form-urlencoded"}) - - # Check for successful login response - assert response.status_code == 200 - data = response.json() - assert "access_token" in data - assert data["token_type"] == "bearer" - - # Use the decode_token method from jwt_service to decode the JWT - decoded_token = decode_token(data["access_token"]) - assert decoded_token is not None, "Failed to decode token" - assert decoded_token["role"] == "AUTHENTICATED", "The user role should be AUTHENTICATED" - -@pytest.mark.asyncio -async def test_login_user_not_found(async_client): - form_data = { - "username": "nonexistentuser@here.edu", - "password": "DoesNotMatter123!" - } - response = await async_client.post("/login/", data=urlencode(form_data), headers={"Content-Type": "application/x-www-form-urlencoded"}) - assert response.status_code == 401 - assert "Incorrect email or password." in response.json().get("detail", "") - -@pytest.mark.asyncio -async def test_login_incorrect_password(async_client, verified_user): - form_data = { - "username": verified_user.email, - "password": "IncorrectPassword123!" - } - response = await async_client.post("/login/", data=urlencode(form_data), headers={"Content-Type": "application/x-www-form-urlencoded"}) - assert response.status_code == 401 - assert "Incorrect email or password." in response.json().get("detail", "") - -@pytest.mark.asyncio -async def test_login_unverified_user(async_client, unverified_user): - form_data = { - "username": unverified_user.email, - "password": "MySuperPassword$1234" - } - response = await async_client.post("/login/", data=urlencode(form_data), headers={"Content-Type": "application/x-www-form-urlencoded"}) - assert response.status_code == 401 - -@pytest.mark.asyncio -async def test_login_locked_user(async_client, locked_user): - form_data = { - "username": locked_user.email, - "password": "MySuperPassword$1234" - } - response = await async_client.post("/login/", data=urlencode(form_data), headers={"Content-Type": "application/x-www-form-urlencoded"}) - assert response.status_code == 400 - assert "Account locked due to too many failed login attempts." in response.json().get("detail", "") -@pytest.mark.asyncio -async def test_delete_user_does_not_exist(async_client, admin_token): - non_existent_user_id = "00000000-0000-0000-0000-000000000000" # Valid UUID format - headers = {"Authorization": f"Bearer {admin_token}"} - delete_response = await async_client.delete(f"/users/{non_existent_user_id}", headers=headers) - assert delete_response.status_code == 404 - -@pytest.mark.asyncio -async def test_update_user_github(async_client, admin_user, admin_token): - updated_data = {"github_profile_url": "http://www.github.com/kaw393939"} - headers = {"Authorization": f"Bearer {admin_token}"} - response = await async_client.put(f"/users/{admin_user.id}", json=updated_data, headers=headers) - assert response.status_code == 200 - assert response.json()["github_profile_url"] == updated_data["github_profile_url"] - -@pytest.mark.asyncio -async def test_update_user_linkedin(async_client, admin_user, admin_token): - updated_data = {"linkedin_profile_url": "http://www.linkedin.com/kaw393939"} - headers = {"Authorization": f"Bearer {admin_token}"} - response = await async_client.put(f"/users/{admin_user.id}", json=updated_data, headers=headers) - assert response.status_code == 200 - assert response.json()["linkedin_profile_url"] == updated_data["linkedin_profile_url"] - -@pytest.mark.asyncio -async def test_list_users_as_admin(async_client, admin_token): - response = await async_client.get( - "/users/", - headers={"Authorization": f"Bearer {admin_token}"} - ) - assert response.status_code == 200 - assert 'items' in response.json() - -@pytest.mark.asyncio -async def test_list_users_as_manager(async_client, manager_token): - response = await async_client.get( - "/users/", - headers={"Authorization": f"Bearer {manager_token}"} - ) - assert response.status_code == 200 - -@pytest.mark.asyncio -async def test_list_users_unauthorized(async_client, user_token): - response = await async_client.get( - "/users/", - headers={"Authorization": f"Bearer {user_token}"} - ) - assert response.status_code == 403 # Forbidden, as expected for regular user +from builtins import str +import pytest +from httpx import AsyncClient +from app.main import app +from app.models.user_model import User +from app.utils.nickname_gen import generate_nickname +from app.utils.security import hash_password +from app.services.jwt_service import decode_token # Import your FastAPI app + +# Example of a test function using the async_client fixture +@pytest.mark.asyncio +async def test_create_user_access_denied(async_client, user_token, email_service): + headers = {"Authorization": f"Bearer {user_token}"} + # Define user data for the test + user_data = { + "nickname": generate_nickname(), + "email": "test@example.com", + "password": "sS#fdasrongPassword123!", + } + # Send a POST request to create a user + response = await async_client.post("/users/", json=user_data, headers=headers) + # Asserts + assert response.status_code == 403 + +# You can similarly refactor other test functions to use the async_client fixture +@pytest.mark.asyncio +async def test_retrieve_user_access_denied(async_client, verified_user, user_token): + headers = {"Authorization": f"Bearer {user_token}"} + response = await async_client.get(f"/users/{verified_user.id}", headers=headers) + assert response.status_code == 403 + +@pytest.mark.asyncio +async def test_retrieve_user_access_allowed(async_client, admin_user, admin_token): + headers = {"Authorization": f"Bearer {admin_token}"} + response = await async_client.get(f"/users/{admin_user.id}", headers=headers) + assert response.status_code == 200 + assert response.json()["id"] == str(admin_user.id) + +@pytest.mark.asyncio +async def test_update_user_email_access_denied(async_client, verified_user, user_token): + updated_data = {"email": f"updated_{verified_user.id}@example.com"} + headers = {"Authorization": f"Bearer {user_token}"} + response = await async_client.put(f"/users/{verified_user.id}", json=updated_data, headers=headers) + assert response.status_code == 403 + +@pytest.mark.asyncio +async def test_update_user_email_access_allowed(async_client, admin_user, admin_token): + updated_data = {"email": f"updated_{admin_user.id}@example.com"} + headers = {"Authorization": f"Bearer {admin_token}"} + response = await async_client.put(f"/users/{admin_user.id}", json=updated_data, headers=headers) + assert response.status_code == 200 + assert response.json()["email"] == updated_data["email"] + + +@pytest.mark.asyncio +async def test_delete_user(async_client, admin_user, admin_token): + headers = {"Authorization": f"Bearer {admin_token}"} + delete_response = await async_client.delete(f"/users/{admin_user.id}", headers=headers) + assert delete_response.status_code == 204 + # Verify the user is deleted + fetch_response = await async_client.get(f"/users/{admin_user.id}", headers=headers) + assert fetch_response.status_code == 404 + +@pytest.mark.asyncio +async def test_create_user_duplicate_email(async_client, verified_user): + user_data = { + "email": verified_user.email, + "password": "AnotherPassword123!", + } + response = await async_client.post("/register/", json=user_data) + assert response.status_code == 400 + assert "Email already exists" in response.json().get("detail", "") + +@pytest.mark.asyncio +async def test_create_user_invalid_email(async_client): + user_data = { + "email": "notanemail", + "password": "ValidPassword123!", + } + response = await async_client.post("/register/", json=user_data) + assert response.status_code == 422 + +import pytest +from app.services.jwt_service import decode_token +from urllib.parse import urlencode + +@pytest.mark.asyncio +async def test_login_success(async_client, verified_user): + # Attempt to login with the test user + form_data = { + "username": verified_user.email, + "password": "MySuperPassword$1234" + } + response = await async_client.post("/login/", data=urlencode(form_data), headers={"Content-Type": "application/x-www-form-urlencoded"}) + + # Check for successful login response + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + # Use the decode_token method from jwt_service to decode the JWT + decoded_token = decode_token(data["access_token"]) + assert decoded_token is not None, "Failed to decode token" + assert decoded_token["role"] == "AUTHENTICATED", "The user role should be AUTHENTICATED" + +@pytest.mark.asyncio +async def test_login_user_not_found(async_client): + form_data = { + "username": "nonexistentuser@here.edu", + "password": "DoesNotMatter123!" + } + response = await async_client.post("/login/", data=urlencode(form_data), headers={"Content-Type": "application/x-www-form-urlencoded"}) + assert response.status_code == 401 + assert "The email or password is incorrect, the email is not verified, or the account is locked." in response.json().get("detail", "") + +@pytest.mark.asyncio +async def test_login_incorrect_password(async_client, verified_user): + form_data = { + "username": verified_user.email, + "password": "IncorrectPassword123!" + } + response = await async_client.post("/login/", data=urlencode(form_data), headers={"Content-Type": "application/x-www-form-urlencoded"}) + assert response.status_code == 401 + assert "The email or password is incorrect, the email is not verified, or the account is locked." in response.json().get("detail", "") + +@pytest.mark.asyncio +async def test_login_unverified_user(async_client, unverified_user): + form_data = { + "username": unverified_user.email, + "password": "MySuperPassword$1234" + } + response = await async_client.post("/login/", data=urlencode(form_data), headers={"Content-Type": "application/x-www-form-urlencoded"}) + assert response.status_code == 401 + +@pytest.mark.asyncio +async def test_login_locked_user(async_client, locked_user): + form_data = { + "username": locked_user.email, + "password": "MySuperPassword$1234" + } + response = await async_client.post("/login/", data=urlencode(form_data), headers={"Content-Type": "application/x-www-form-urlencoded"}) + assert response.status_code == 400 + assert "Account locked due to too many failed login attempts." in response.json().get("detail", "") + +@pytest.mark.asyncio +async def test_delete_user_does_not_exist(async_client, admin_token): + non_existent_user_id = "00000000-0000-0000-0000-000000000000" # Valid UUID format + headers = {"Authorization": f"Bearer {admin_token}"} + delete_response = await async_client.delete(f"/users/{non_existent_user_id}", headers=headers) + assert delete_response.status_code == 404 + +@pytest.mark.asyncio +async def test_update_user_github(async_client, admin_user, admin_token): + updated_data = {"github_profile_url": "http://www.github.com/kaw393939"} + headers = {"Authorization": f"Bearer {admin_token}"} + response = await async_client.put(f"/users/{admin_user.id}", json=updated_data, headers=headers) + assert response.status_code == 200 + assert response.json()["github_profile_url"] == updated_data["github_profile_url"] + +@pytest.mark.asyncio +async def test_update_user_linkedin(async_client, admin_user, admin_token): + updated_data = {"linkedin_profile_url": "http://www.linkedin.com/kaw393939"} + headers = {"Authorization": f"Bearer {admin_token}"} + response = await async_client.put(f"/users/{admin_user.id}", json=updated_data, headers=headers) + assert response.status_code == 200 + assert response.json()["linkedin_profile_url"] == updated_data["linkedin_profile_url"] + +@pytest.mark.asyncio +async def test_list_users_as_admin(async_client, admin_token): + response = await async_client.get( + "/users/", + headers={"Authorization": f"Bearer {admin_token}"} + ) + assert response.status_code == 200 + assert 'items' in response.json() + +@pytest.mark.asyncio +async def test_list_users_as_manager(async_client, manager_token): + response = await async_client.get( + "/users/", + headers={"Authorization": f"Bearer {manager_token}"} + ) + assert response.status_code == 200 + +@pytest.mark.asyncio +async def test_list_users_unauthorized(async_client, user_token): + response = await async_client.get( + "/users/", + headers={"Authorization": f"Bearer {user_token}"} + ) + assert response.status_code == 403 # Forbidden, as expected for regular user diff --git a/tests/test_conftest.py b/tests/test_conftest.py index f86b2da36..b7161bddb 100644 --- a/tests/test_conftest.py +++ b/tests/test_conftest.py @@ -1,64 +1,64 @@ -# test_users.py - -from builtins import len -import pytest -from httpx import AsyncClient -from sqlalchemy.future import select - -from app.models.user_model import User, UserRole -from app.utils.security import verify_password - -@pytest.mark.asyncio -async def test_user_creation(db_session, verified_user): - """Test that a user is correctly created and stored in the database.""" - result = await db_session.execute(select(User).filter_by(email=verified_user.email)) - stored_user = result.scalars().first() - assert stored_user is not None - assert stored_user.email == verified_user.email - assert verify_password("MySuperPassword$1234", stored_user.hashed_password) - -# Apply similar corrections to other test functions -@pytest.mark.asyncio -async def test_locked_user(db_session, locked_user): - result = await db_session.execute(select(User).filter_by(email=locked_user.email)) - stored_user = result.scalars().first() - assert stored_user.is_locked - -@pytest.mark.asyncio -async def test_verified_user(db_session, verified_user): - result = await db_session.execute(select(User).filter_by(email=verified_user.email)) - stored_user = result.scalars().first() - assert stored_user.email_verified - -@pytest.mark.asyncio -async def test_user_role(db_session, admin_user): - result = await db_session.execute(select(User).filter_by(email=admin_user.email)) - stored_user = result.scalars().first() - assert stored_user.role == UserRole.ADMIN - -@pytest.mark.asyncio -async def test_bulk_user_creation_performance(db_session, users_with_same_role_50_users): - result = await db_session.execute(select(User).filter_by(role=UserRole.AUTHENTICATED)) - users = result.scalars().all() - assert len(users) == 50 - -@pytest.mark.asyncio -async def test_password_hashing(user): - assert verify_password("MySuperPassword$1234", user.hashed_password) - -@pytest.mark.asyncio -async def test_user_unlock(db_session, locked_user): - locked_user.unlock_account() - await db_session.commit() - result = await db_session.execute(select(User).filter_by(email=locked_user.email)) - updated_user = result.scalars().first() - assert not updated_user.is_locked - -@pytest.mark.asyncio -async def test_update_professional_status(db_session, verified_user): - verified_user.update_professional_status(True) - await db_session.commit() - result = await db_session.execute(select(User).filter_by(email=verified_user.email)) - updated_user = result.scalars().first() - assert updated_user.is_professional - assert updated_user.professional_status_updated_at is not None +# test_users.py + +from builtins import len +import pytest +from httpx import AsyncClient +from sqlalchemy.future import select + +from app.models.user_model import User, UserRole +from app.utils.security import verify_password + +@pytest.mark.asyncio +async def test_user_creation(db_session, verified_user): + """Test that a user is correctly created and stored in the database.""" + result = await db_session.execute(select(User).filter_by(email=verified_user.email)) + stored_user = result.scalars().first() + assert stored_user is not None + assert stored_user.email == verified_user.email + assert verify_password("MySuperPassword$1234", stored_user.hashed_password) + +# Apply similar corrections to other test functions +@pytest.mark.asyncio +async def test_locked_user(db_session, locked_user): + result = await db_session.execute(select(User).filter_by(email=locked_user.email)) + stored_user = result.scalars().first() + assert stored_user.is_locked + +@pytest.mark.asyncio +async def test_verified_user(db_session, verified_user): + result = await db_session.execute(select(User).filter_by(email=verified_user.email)) + stored_user = result.scalars().first() + assert stored_user.email_verified + +@pytest.mark.asyncio +async def test_user_role(db_session, admin_user): + result = await db_session.execute(select(User).filter_by(email=admin_user.email)) + stored_user = result.scalars().first() + assert stored_user.role == UserRole.ADMIN + +@pytest.mark.asyncio +async def test_bulk_user_creation_performance(db_session, users_with_same_role_50_users): + result = await db_session.execute(select(User).filter_by(role=UserRole.AUTHENTICATED)) + users = result.scalars().all() + assert len(users) == 50 + +@pytest.mark.asyncio +async def test_password_hashing(user): + assert verify_password("MySuperPassword$1234", user.hashed_password) + +@pytest.mark.asyncio +async def test_user_unlock(db_session, locked_user): + locked_user.unlock_account() + await db_session.commit() + result = await db_session.execute(select(User).filter_by(email=locked_user.email)) + updated_user = result.scalars().first() + assert not updated_user.is_locked + +@pytest.mark.asyncio +async def test_update_professional_status(db_session, verified_user): + verified_user.update_professional_status(True) + await db_session.commit() + result = await db_session.execute(select(User).filter_by(email=verified_user.email)) + updated_user = result.scalars().first() + assert updated_user.is_professional + assert updated_user.professional_status_updated_at is not None diff --git a/tests/test_email.py b/tests/test_email.py index 9c0c22f81..a21ccd622 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -1,14 +1,14 @@ -import pytest -from app.services.email_service import EmailService -from app.utils.template_manager import TemplateManager - - -@pytest.mark.asyncio -async def test_send_markdown_email(email_service): - user_data = { - "email": "test@example.com", - "name": "Test User", - "verification_url": "http://example.com/verify?token=abc123" - } - await email_service.send_user_email(user_data, 'email_verification') - # Manual verification in Mailtrap +import pytest +from app.services.email_service import EmailService +from app.utils.template_manager import TemplateManager + + +@pytest.mark.asyncio +async def test_send_markdown_email(email_service): + user_data = { + "email": "test@example.com", + "name": "Test User", + "verification_url": "http://example.com/verify?token=abc123" + } + await email_service.send_user_email(user_data, 'email_verification') + # Manual verification in Mailtrap diff --git a/tests/test_link_generation.py b/tests/test_link_generation.py index 37faa88ac..bdac3ec63 100644 --- a/tests/test_link_generation.py +++ b/tests/test_link_generation.py @@ -1,51 +1,51 @@ -from builtins import len, max, sorted, str -from unittest.mock import MagicMock -from urllib.parse import parse_qs, urlparse, parse_qsl, urlunparse, urlencode -from uuid import uuid4 - -import pytest -from fastapi import Request - -from app.utils.link_generation import create_link, create_pagination_link, create_user_links, generate_pagination_links - -from urllib.parse import urlparse, parse_qs, urlunparse, urlencode - -def normalize_url(url): - """Normalize the URL for consistent comparison by sorting query parameters.""" - parsed_url = urlparse(url) - query_params = parse_qs(parsed_url.query, keep_blank_values=True) - # Sort the query parameters by key, and sort their values if there are multiple for a single key - sorted_query_items = sorted((k, sorted(v)) for k, v in query_params.items()) - # Convert the sorted query parameters back to a query string - encoded_query = urlencode(sorted_query_items, doseq=True) - normalized_url = urlunparse(parsed_url._replace(query=encoded_query)) - return normalized_url.rstrip('/') - - -@pytest.fixture -def mock_request(): - request = MagicMock(spec=Request) - request.url_for = MagicMock(side_effect=lambda action, user_id: f"http://testserver/{action}/{user_id}") - request.url = "http://testserver/users" - return request - -def test_create_link(): - link = create_link("self", "http://example.com", "GET", "view") - assert normalize_url(str(link.href)) == "http://example.com" - -def test_create_user_links(mock_request): - user_id = uuid4() - links = create_user_links(user_id, mock_request) - assert len(links) == 3 - assert normalize_url(str(links[0].href)) == f"http://testserver/get_user/{user_id}" - assert normalize_url(str(links[1].href)) == f"http://testserver/update_user/{user_id}" - assert normalize_url(str(links[2].href)) == f"http://testserver/delete_user/{user_id}" - -def test_generate_pagination_links(mock_request): - skip = 10 - limit = 5 - total_items = 50 - links = generate_pagination_links(mock_request, skip, limit, total_items) - assert len(links) >= 4 - expected_self_url = "http://testserver/users?limit=5&skip=10" - assert normalize_url(str(links[0].href)) == normalize_url(expected_self_url), "Self link should match expected URL" +from builtins import len, max, sorted, str +from unittest.mock import MagicMock +from urllib.parse import parse_qs, urlparse, parse_qsl, urlunparse, urlencode +from uuid import uuid4 + +import pytest +from fastapi import Request + +from app.utils.link_generation import create_link, create_pagination_link, create_user_links, generate_pagination_links + +from urllib.parse import urlparse, parse_qs, urlunparse, urlencode + +def normalize_url(url): + """Normalize the URL for consistent comparison by sorting query parameters.""" + parsed_url = urlparse(url) + query_params = parse_qs(parsed_url.query, keep_blank_values=True) + # Sort the query parameters by key, and sort their values if there are multiple for a single key + sorted_query_items = sorted((k, sorted(v)) for k, v in query_params.items()) + # Convert the sorted query parameters back to a query string + encoded_query = urlencode(sorted_query_items, doseq=True) + normalized_url = urlunparse(parsed_url._replace(query=encoded_query)) + return normalized_url.rstrip('/') + + +@pytest.fixture +def mock_request(): + request = MagicMock(spec=Request) + request.url_for = MagicMock(side_effect=lambda action, user_id: f"http://testserver/{action}/{user_id}") + request.url = "http://testserver/users" + return request + +def test_create_link(): + link = create_link("self", "http://example.com", "GET", "view") + assert normalize_url(str(link.href)) == "http://example.com" + +def test_create_user_links(mock_request): + user_id = uuid4() + links = create_user_links(user_id, mock_request) + assert len(links) == 3 + assert normalize_url(str(links[0].href)) == f"http://testserver/get_user/{user_id}" + assert normalize_url(str(links[1].href)) == f"http://testserver/update_user/{user_id}" + assert normalize_url(str(links[2].href)) == f"http://testserver/delete_user/{user_id}" + +def test_generate_pagination_links(mock_request): + skip = 10 + limit = 5 + total_items = 50 + links = generate_pagination_links(mock_request, skip, limit, total_items) + assert len(links) >= 4 + expected_self_url = "http://testserver/users?limit=5&skip=10" + assert normalize_url(str(links[0].href)) == normalize_url(expected_self_url), "Self link should match expected URL" diff --git a/tests/test_models/test_user_model.py b/tests/test_models/test_user_model.py index 7deb6e393..81dea3d47 100644 --- a/tests/test_models/test_user_model.py +++ b/tests/test_models/test_user_model.py @@ -1,152 +1,152 @@ -from builtins import repr -from datetime import datetime, timezone -import pytest -from sqlalchemy.ext.asyncio import AsyncSession -from app.models.user_model import User, UserRole - -@pytest.mark.asyncio -async def test_user_role(db_session: AsyncSession, user: User, admin_user: User, manager_user: User): - """ - Tests that the default role is assigned correctly and can be updated. - """ - assert user.role == UserRole.AUTHENTICATED, "Default role should be USER" - assert admin_user.role == UserRole.ADMIN, "Admin role should be correctly assigned" - assert manager_user.role == UserRole.MANAGER, "Pro role should be correctly assigned" - -@pytest.mark.asyncio -async def test_has_role(user: User, admin_user: User, manager_user: User): - """ - Tests the has_role method to ensure it accurately checks the user's role. - """ - assert user.has_role(UserRole.AUTHENTICATED), "User should have USER role" - assert not user.has_role(UserRole.ADMIN), "User should not have ADMIN role" - assert admin_user.has_role(UserRole.ADMIN), "Admin user should have ADMIN role" - assert manager_user.has_role(UserRole.MANAGER), "Pro user should have PRO role" - -@pytest.mark.asyncio -async def test_user_repr(user: User): - """ - Tests the __repr__ method for accurate representation of the User object. - """ - assert repr(user) == f"", "__repr__ should include nickname and role" - -@pytest.mark.asyncio -async def test_failed_login_attempts_increment(db_session: AsyncSession, user: User): - """ - Tests that failed login attempts can be incremented and persisted correctly. - """ - initial_attempts = user.failed_login_attempts - user.failed_login_attempts += 1 - await db_session.commit() - await db_session.refresh(user) - assert user.failed_login_attempts == initial_attempts + 1, "Failed login attempts should increment" - -@pytest.mark.asyncio -async def test_last_login_update(db_session: AsyncSession, user: User): - """ - Tests updating the last login timestamp. - """ - new_last_login = datetime.now(timezone.utc) - user.last_login_at = new_last_login - await db_session.commit() - await db_session.refresh(user) - assert user.last_login_at == new_last_login, "Last login timestamp should update correctly" - -@pytest.mark.asyncio -async def test_account_lock_and_unlock(db_session: AsyncSession, user: User): - """ - Tests locking and unlocking the user account. - """ - # Initially, the account should not be locked. - assert not user.is_locked, "Account should initially be unlocked" - - # Lock the account and verify. - user.lock_account() - await db_session.commit() - await db_session.refresh(user) - assert user.is_locked, "Account should be locked after calling lock_account()" - - # Unlock the account and verify. - user.unlock_account() - await db_session.commit() - await db_session.refresh(user) - assert not user.is_locked, "Account should be unlocked after calling unlock_account()" - -@pytest.mark.asyncio -async def test_email_verification(db_session: AsyncSession, user: User): - """ - Tests the email verification functionality. - """ - # Initially, the email should not be verified. - assert not user.email_verified, "Email should initially be unverified" - - # Verify the email and check. - user.verify_email() - await db_session.commit() - await db_session.refresh(user) - assert user.email_verified, "Email should be verified after calling verify_email()" - -@pytest.mark.asyncio -async def test_user_profile_pic_url_update(db_session: AsyncSession, user: User): - """ - Tests the profile pic update functionality. - """ - # Initially, the profile pic should be updated. - - # Verify the email and check. - profile_pic_url = "http://myprofile/picture.png" - user.profile_picture_url = profile_pic_url - await db_session.commit() - await db_session.refresh(user) - assert user.profile_picture_url == profile_pic_url, "The profile pic did not update" - -@pytest.mark.asyncio -async def test_user_linkedin_url_update(db_session: AsyncSession, user: User): - """ - Tests the profile pic update functionality. - """ - # Initially, the linkedin should be updated. - - # Verify the linkedin profile url. - profile_linkedin_url = "http://www.linkedin.com/profile" - user.linkedin_profile_url = profile_linkedin_url - await db_session.commit() - await db_session.refresh(user) - assert user.linkedin_profile_url == profile_linkedin_url, "The profile pic did not update" - - -@pytest.mark.asyncio -async def test_user_github_url_update(db_session: AsyncSession, user: User): - """ - Tests the profile pic update functionality. - """ - # Initially, the linkedin should be updated. - - # Verify the linkedin profile url. - profile_github_url = "http://www.github.com/profile" - user.github_profile_url = profile_github_url - await db_session.commit() - await db_session.refresh(user) - assert user.github_profile_url == profile_github_url, "The github did not update" - - -@pytest.mark.asyncio -async def test_default_role_assignment(db_session: AsyncSession): - """ - Tests that a user without a specified role defaults to 'anonymous' or the expected default role. - """ - user = User(nickname="noob", email="newuser@example.com", hashed_password="hashed_password") - db_session.add(user) - await db_session.commit() - await db_session.refresh(user) - assert user.role == UserRole.ANONYMOUS, "Default role should be 'anonymous' if not specified" - -@pytest.mark.asyncio -async def test_update_user_role(db_session: AsyncSession, user: User): - """ - Tests updating the user's role and ensuring it persists correctly. - """ - user.role = UserRole.ADMIN - await db_session.commit() - await db_session.refresh(user) - assert user.role == UserRole.ADMIN, "Role update should persist correctly in the database" +from builtins import repr +from datetime import datetime, timezone +import pytest +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.user_model import User, UserRole + +@pytest.mark.asyncio +async def test_user_role(db_session: AsyncSession, user: User, admin_user: User, manager_user: User): + """ + Tests that the default role is assigned correctly and can be updated. + """ + assert user.role == UserRole.AUTHENTICATED, "Default role should be USER" + assert admin_user.role == UserRole.ADMIN, "Admin role should be correctly assigned" + assert manager_user.role == UserRole.MANAGER, "Pro role should be correctly assigned" + +@pytest.mark.asyncio +async def test_has_role(user: User, admin_user: User, manager_user: User): + """ + Tests the has_role method to ensure it accurately checks the user's role. + """ + assert user.has_role(UserRole.AUTHENTICATED), "User should have USER role" + assert not user.has_role(UserRole.ADMIN), "User should not have ADMIN role" + assert admin_user.has_role(UserRole.ADMIN), "Admin user should have ADMIN role" + assert manager_user.has_role(UserRole.MANAGER), "Pro user should have PRO role" + +@pytest.mark.asyncio +async def test_user_repr(user: User): + """ + Tests the __repr__ method for accurate representation of the User object. + """ + assert repr(user) == f"", "__repr__ should include nickname and role" + +@pytest.mark.asyncio +async def test_failed_login_attempts_increment(db_session: AsyncSession, user: User): + """ + Tests that failed login attempts can be incremented and persisted correctly. + """ + initial_attempts = user.failed_login_attempts + user.failed_login_attempts += 1 + await db_session.commit() + await db_session.refresh(user) + assert user.failed_login_attempts == initial_attempts + 1, "Failed login attempts should increment" + +@pytest.mark.asyncio +async def test_last_login_update(db_session: AsyncSession, user: User): + """ + Tests updating the last login timestamp. + """ + new_last_login = datetime.now(timezone.utc) + user.last_login_at = new_last_login + await db_session.commit() + await db_session.refresh(user) + assert user.last_login_at == new_last_login, "Last login timestamp should update correctly" + +@pytest.mark.asyncio +async def test_account_lock_and_unlock(db_session: AsyncSession, user: User): + """ + Tests locking and unlocking the user account. + """ + # Initially, the account should not be locked. + assert not user.is_locked, "Account should initially be unlocked" + + # Lock the account and verify. + user.lock_account() + await db_session.commit() + await db_session.refresh(user) + assert user.is_locked, "Account should be locked after calling lock_account()" + + # Unlock the account and verify. + user.unlock_account() + await db_session.commit() + await db_session.refresh(user) + assert not user.is_locked, "Account should be unlocked after calling unlock_account()" + +@pytest.mark.asyncio +async def test_email_verification(db_session: AsyncSession, user: User): + """ + Tests the email verification functionality. + """ + # Initially, the email should not be verified. + assert not user.email_verified, "Email should initially be unverified" + + # Verify the email and check. + user.verify_email() + await db_session.commit() + await db_session.refresh(user) + assert user.email_verified, "Email should be verified after calling verify_email()" + +@pytest.mark.asyncio +async def test_user_profile_pic_url_update(db_session: AsyncSession, user: User): + """ + Tests the profile pic update functionality. + """ + # Initially, the profile pic should be updated. + + # Verify the email and check. + profile_pic_url = "http://myprofile/picture.png" + user.profile_picture_url = profile_pic_url + await db_session.commit() + await db_session.refresh(user) + assert user.profile_picture_url == profile_pic_url, "The profile pic did not update" + +@pytest.mark.asyncio +async def test_user_linkedin_url_update(db_session: AsyncSession, user: User): + """ + Tests the profile pic update functionality. + """ + # Initially, the linkedin should be updated. + + # Verify the linkedin profile url. + profile_linkedin_url = "http://www.linkedin.com/profile" + user.linkedin_profile_url = profile_linkedin_url + await db_session.commit() + await db_session.refresh(user) + assert user.linkedin_profile_url == profile_linkedin_url, "The profile pic did not update" + + +@pytest.mark.asyncio +async def test_user_github_url_update(db_session: AsyncSession, user: User): + """ + Tests the profile pic update functionality. + """ + # Initially, the linkedin should be updated. + + # Verify the linkedin profile url. + profile_github_url = "http://www.github.com/profile" + user.github_profile_url = profile_github_url + await db_session.commit() + await db_session.refresh(user) + assert user.github_profile_url == profile_github_url, "The github did not update" + + +@pytest.mark.asyncio +async def test_default_role_assignment(db_session: AsyncSession): + """ + Tests that a user without a specified role defaults to 'anonymous' or the expected default role. + """ + user = User(nickname="noob", email="newuser@example.com", hashed_password="hashed_password") + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + assert user.role == UserRole.ANONYMOUS, "Default role should be 'anonymous' if not specified" + +@pytest.mark.asyncio +async def test_update_user_role(db_session: AsyncSession, user: User): + """ + Tests updating the user's role and ensuring it persists correctly. + """ + user.role = UserRole.ADMIN + await db_session.commit() + await db_session.refresh(user) + assert user.role == UserRole.ADMIN, "Role update should persist correctly in the database" diff --git a/tests/test_schemas/test_user_schemas.py b/tests/test_schemas/test_user_schemas.py index c5b92e148..a229a83e0 100644 --- a/tests/test_schemas/test_user_schemas.py +++ b/tests/test_schemas/test_user_schemas.py @@ -1,69 +1,69 @@ -from builtins import str -import pytest -from pydantic import ValidationError -from datetime import datetime -from app.schemas.user_schemas import UserBase, UserCreate, UserUpdate, UserResponse, UserListResponse, LoginRequest - -# Tests for UserBase -def test_user_base_valid(user_base_data): - user = UserBase(**user_base_data) - assert user.nickname == user_base_data["nickname"] - assert user.email == user_base_data["email"] - -# Tests for UserCreate -def test_user_create_valid(user_create_data): - user = UserCreate(**user_create_data) - assert user.nickname == user_create_data["nickname"] - assert user.password == user_create_data["password"] - -# Tests for UserUpdate -def test_user_update_valid(user_update_data): - user_update = UserUpdate(**user_update_data) - assert user_update.email == user_update_data["email"] - assert user_update.first_name == user_update_data["first_name"] - -# Tests for UserResponse -def test_user_response_valid(user_response_data): - user = UserResponse(**user_response_data) - assert user.id == user_response_data["id"] - # assert user.last_login_at == user_response_data["last_login_at"] - -# Tests for LoginRequest -def test_login_request_valid(login_request_data): - login = LoginRequest(**login_request_data) - assert login.email == login_request_data["email"] - assert login.password == login_request_data["password"] - -# Parametrized tests for nickname and email validation -@pytest.mark.parametrize("nickname", ["test_user", "test-user", "testuser123", "123test"]) -def test_user_base_nickname_valid(nickname, user_base_data): - user_base_data["nickname"] = nickname - user = UserBase(**user_base_data) - assert user.nickname == nickname - -@pytest.mark.parametrize("nickname", ["test user", "test?user", "", "us"]) -def test_user_base_nickname_invalid(nickname, user_base_data): - user_base_data["nickname"] = nickname - with pytest.raises(ValidationError): - UserBase(**user_base_data) - -# Parametrized tests for URL validation -@pytest.mark.parametrize("url", ["http://valid.com/profile.jpg", "https://valid.com/profile.png", None]) -def test_user_base_url_valid(url, user_base_data): - user_base_data["profile_picture_url"] = url - user = UserBase(**user_base_data) - assert user.profile_picture_url == url - -@pytest.mark.parametrize("url", ["ftp://invalid.com/profile.jpg", "http//invalid", "https//invalid"]) -def test_user_base_url_invalid(url, user_base_data): - user_base_data["profile_picture_url"] = url - with pytest.raises(ValidationError): - UserBase(**user_base_data) - -# Tests for UserBase -def test_user_base_invalid_email(user_base_data_invalid): - with pytest.raises(ValidationError) as exc_info: - user = UserBase(**user_base_data_invalid) - - assert "value is not a valid email address" in str(exc_info.value) +from builtins import str +import pytest +from pydantic import ValidationError +from datetime import datetime +from app.schemas.user_schemas import UserBase, UserCreate, UserUpdate, UserResponse, UserListResponse, LoginRequest + +# Tests for UserBase +def test_user_base_valid(user_base_data): + user = UserBase(**user_base_data) + assert user.nickname == user_base_data["nickname"] + assert user.email == user_base_data["email"] + +# Tests for UserCreate +def test_user_create_valid(user_create_data): + user = UserCreate(**user_create_data) + assert user.nickname == user_create_data["nickname"] + assert user.password == user_create_data["password"] + +# Tests for UserUpdate +def test_user_update_valid(user_update_data): + user_update = UserUpdate(**user_update_data) + assert user_update.email == user_update_data["email"] + assert user_update.first_name == user_update_data["first_name"] + +# Tests for UserResponse +def test_user_response_valid(user_response_data): + user = UserResponse(**user_response_data) + assert user.id == user_response_data["id"] + # assert user.last_login_at == user_response_data["last_login_at"] + +# Tests for LoginRequest +def test_login_request_valid(login_request_data): + login = LoginRequest(**login_request_data) + assert login.email == login_request_data["email"] + assert login.password == login_request_data["password"] + +# Parametrized tests for nickname and email validation +@pytest.mark.parametrize("nickname", ["test_user", "test-user", "testuser123", "123test"]) +def test_user_base_nickname_valid(nickname, user_base_data): + user_base_data["nickname"] = nickname + user = UserBase(**user_base_data) + assert user.nickname == nickname + +@pytest.mark.parametrize("nickname", ["test user", "test?user", "", "us"]) +def test_user_base_nickname_invalid(nickname, user_base_data): + user_base_data["nickname"] = nickname + with pytest.raises(ValidationError): + UserBase(**user_base_data) + +# Parametrized tests for URL validation +@pytest.mark.parametrize("url", ["http://valid.com/profile.jpg", "https://valid.com/profile.png", None]) +def test_user_base_url_valid(url, user_base_data): + user_base_data["profile_picture_url"] = url + user = UserBase(**user_base_data) + assert user.profile_picture_url == url + +@pytest.mark.parametrize("url", ["ftp://invalid.com/profile.jpg", "http//invalid", "https//invalid"]) +def test_user_base_url_invalid(url, user_base_data): + user_base_data["profile_picture_url"] = url + with pytest.raises(ValidationError): + UserBase(**user_base_data) + +# Tests for UserBase +def test_user_base_invalid_email(user_base_data_invalid): + with pytest.raises(ValidationError) as exc_info: + user = UserBase(**user_base_data_invalid) + + assert "value is not a valid email address" in str(exc_info.value) assert "john.doe.example.com" in str(exc_info.value) \ No newline at end of file diff --git a/tests/test_security.py b/tests/test_security.py index cedc7cef6..a9429af26 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1,67 +1,67 @@ -# test_security.py -from builtins import RuntimeError, ValueError, isinstance, str -import pytest -from app.utils.security import hash_password, verify_password - -def test_hash_password(): - """Test that hashing password returns a bcrypt hashed string.""" - password = "secure_password" - hashed = hash_password(password) - assert hashed is not None - assert isinstance(hashed, str) - assert hashed.startswith('$2b$') - -def test_hash_password_with_different_rounds(): - """Test hashing with different cost factors.""" - password = "secure_password" - rounds = 10 - hashed_10 = hash_password(password, rounds) - rounds = 12 - hashed_12 = hash_password(password, rounds) - assert hashed_10 != hashed_12, "Hashes should differ with different cost factors" - -def test_verify_password_correct(): - """Test verifying the correct password.""" - password = "secure_password" - hashed = hash_password(password) - assert verify_password(password, hashed) is True - -def test_verify_password_incorrect(): - """Test verifying the incorrect password.""" - password = "secure_password" - hashed = hash_password(password) - wrong_password = "incorrect_password" - assert verify_password(wrong_password, hashed) is False - -def test_verify_password_invalid_hash(): - """Test verifying a password against an invalid hash format.""" - with pytest.raises(ValueError): - verify_password("secure_password", "invalid_hash_format") - -@pytest.mark.parametrize("password", [ - "", - " ", - "a"*100 # Long password -]) -def test_hash_password_edge_cases(password): - """Test hashing various edge cases.""" - hashed = hash_password(password) - assert isinstance(hashed, str) and hashed.startswith('$2b$'), "Should handle edge cases properly" - -def test_verify_password_edge_cases(): - """Test verifying passwords with edge cases.""" - password = " " - hashed = hash_password(password) - assert verify_password(password, hashed) is True - assert verify_password("not empty", hashed) is False - -# This function tests the error handling when an internal error occurs in bcrypt -def test_hash_password_internal_error(monkeypatch): - """Test proper error handling when an internal bcrypt error occurs.""" - def mock_bcrypt_gensalt(rounds): - raise RuntimeError("Simulated internal error") - - monkeypatch.setattr("bcrypt.gensalt", mock_bcrypt_gensalt) - with pytest.raises(ValueError): - hash_password("test") - +# test_security.py +from builtins import RuntimeError, ValueError, isinstance, str +import pytest +from app.utils.security import hash_password, verify_password + +def test_hash_password(): + """Test that hashing password returns a bcrypt hashed string.""" + password = "secure_password" + hashed = hash_password(password) + assert hashed is not None + assert isinstance(hashed, str) + assert hashed.startswith('$2b$') + +def test_hash_password_with_different_rounds(): + """Test hashing with different cost factors.""" + password = "secure_password" + rounds = 10 + hashed_10 = hash_password(password, rounds) + rounds = 12 + hashed_12 = hash_password(password, rounds) + assert hashed_10 != hashed_12, "Hashes should differ with different cost factors" + +def test_verify_password_correct(): + """Test verifying the correct password.""" + password = "secure_password" + hashed = hash_password(password) + assert verify_password(password, hashed) is True + +def test_verify_password_incorrect(): + """Test verifying the incorrect password.""" + password = "secure_password" + hashed = hash_password(password) + wrong_password = "incorrect_password" + assert verify_password(wrong_password, hashed) is False + +def test_verify_password_invalid_hash(): + """Test verifying a password against an invalid hash format.""" + with pytest.raises(ValueError): + verify_password("secure_password", "invalid_hash_format") + +@pytest.mark.parametrize("password", [ + "", + " ", + "a"*100 # Long password +]) +def test_hash_password_edge_cases(password): + """Test hashing various edge cases.""" + hashed = hash_password(password) + assert isinstance(hashed, str) and hashed.startswith('$2b$'), "Should handle edge cases properly" + +def test_verify_password_edge_cases(): + """Test verifying passwords with edge cases.""" + password = " " + hashed = hash_password(password) + assert verify_password(password, hashed) is True + assert verify_password("not empty", hashed) is False + +# This function tests the error handling when an internal error occurs in bcrypt +def test_hash_password_internal_error(monkeypatch): + """Test proper error handling when an internal bcrypt error occurs.""" + def mock_bcrypt_gensalt(rounds): + raise RuntimeError("Simulated internal error") + + monkeypatch.setattr("bcrypt.gensalt", mock_bcrypt_gensalt) + with pytest.raises(ValueError): + hash_password("test") + diff --git a/tests/test_services/test_user_service.py b/tests/test_services/test_user_service.py index d0642664e..9a60c062f 100644 --- a/tests/test_services/test_user_service.py +++ b/tests/test_services/test_user_service.py @@ -1,158 +1,158 @@ -from builtins import range -import pytest -from sqlalchemy import select -from app.dependencies import get_settings -from app.models.user_model import User -from app.services.user_service import UserService - -pytestmark = pytest.mark.asyncio - -# Test creating a user with valid data -async def test_create_user_with_valid_data(db_session, email_service): - user_data = { - "email": "valid_user@example.com", - "password": "ValidPassword123!", - } - user = await UserService.create(db_session, user_data, email_service) - assert user is not None - assert user.email == user_data["email"] - -# Test creating a user with invalid data -async def test_create_user_with_invalid_data(db_session, email_service): - user_data = { - "nickname": "", # Invalid nickname - "email": "invalidemail", # Invalid email - "password": "short", # Invalid password - } - user = await UserService.create(db_session, user_data, email_service) - assert user is None - -# Test fetching a user by ID when the user exists -async def test_get_by_id_user_exists(db_session, user): - retrieved_user = await UserService.get_by_id(db_session, user.id) - assert retrieved_user.id == user.id - -# Test fetching a user by ID when the user does not exist -async def test_get_by_id_user_does_not_exist(db_session): - non_existent_user_id = "non-existent-id" - retrieved_user = await UserService.get_by_id(db_session, non_existent_user_id) - assert retrieved_user is None - -# Test fetching a user by nickname when the user exists -async def test_get_by_nickname_user_exists(db_session, user): - retrieved_user = await UserService.get_by_nickname(db_session, user.nickname) - assert retrieved_user.nickname == user.nickname - -# Test fetching a user by nickname when the user does not exist -async def test_get_by_nickname_user_does_not_exist(db_session): - retrieved_user = await UserService.get_by_nickname(db_session, "non_existent_nickname") - assert retrieved_user is None - -# Test fetching a user by email when the user exists -async def test_get_by_email_user_exists(db_session, user): - retrieved_user = await UserService.get_by_email(db_session, user.email) - assert retrieved_user.email == user.email - -# Test fetching a user by email when the user does not exist -async def test_get_by_email_user_does_not_exist(db_session): - retrieved_user = await UserService.get_by_email(db_session, "non_existent_email@example.com") - assert retrieved_user is None - -# Test updating a user with valid data -async def test_update_user_valid_data(db_session, user): - new_email = "updated_email@example.com" - updated_user = await UserService.update(db_session, user.id, {"email": new_email}) - assert updated_user is not None - assert updated_user.email == new_email - -# Test updating a user with invalid data -async def test_update_user_invalid_data(db_session, user): - updated_user = await UserService.update(db_session, user.id, {"email": "invalidemail"}) - assert updated_user is None - -# Test deleting a user who exists -async def test_delete_user_exists(db_session, user): - deletion_success = await UserService.delete(db_session, user.id) - assert deletion_success is True - -# Test attempting to delete a user who does not exist -async def test_delete_user_does_not_exist(db_session): - non_existent_user_id = "non-existent-id" - deletion_success = await UserService.delete(db_session, non_existent_user_id) - assert deletion_success is False - -# Test listing users with pagination -async def test_list_users_with_pagination(db_session, users_with_same_role_50_users): - users_page_1 = await UserService.list_users(db_session, skip=0, limit=10) - users_page_2 = await UserService.list_users(db_session, skip=10, limit=10) - assert len(users_page_1) == 10 - assert len(users_page_2) == 10 - assert users_page_1[0].id != users_page_2[0].id - -# Test registering a user with valid data -async def test_register_user_with_valid_data(db_session, email_service): - user_data = { - "email": "register_valid_user@example.com", - "password": "RegisterValid123!", - } - user = await UserService.register_user(db_session, user_data, email_service) - assert user is not None - assert user.email == user_data["email"] - -# Test attempting to register a user with invalid data -async def test_register_user_with_invalid_data(db_session, email_service): - user_data = { - "email": "registerinvalidemail", # Invalid email - "password": "short", # Invalid password - } - user = await UserService.register_user(db_session, user_data, email_service) - assert user is None - -# Test successful user login -async def test_login_user_successful(db_session, verified_user): - user_data = { - "email": verified_user.email, - "password": "MySuperPassword$1234", - } - logged_in_user = await UserService.login_user(db_session, user_data["email"], user_data["password"]) - assert logged_in_user is not None - -# Test user login with incorrect email -async def test_login_user_incorrect_email(db_session): - user = await UserService.login_user(db_session, "nonexistentuser@noway.com", "Password123!") - assert user is None - -# Test user login with incorrect password -async def test_login_user_incorrect_password(db_session, user): - user = await UserService.login_user(db_session, user.email, "IncorrectPassword!") - assert user is None - -# Test account lock after maximum failed login attempts -async def test_account_lock_after_failed_logins(db_session, verified_user): - max_login_attempts = get_settings().max_login_attempts - for _ in range(max_login_attempts): - await UserService.login_user(db_session, verified_user.email, "wrongpassword") - - is_locked = await UserService.is_account_locked(db_session, verified_user.email) - assert is_locked, "The account should be locked after the maximum number of failed login attempts." - -# Test resetting a user's password -async def test_reset_password(db_session, user): - new_password = "NewPassword123!" - reset_success = await UserService.reset_password(db_session, user.id, new_password) - assert reset_success is True - -# Test verifying a user's email -async def test_verify_email_with_token(db_session, user): - token = "valid_token_example" # This should be set in your user setup if it depends on a real token - user.verification_token = token # Simulating setting the token in the database - await db_session.commit() - result = await UserService.verify_email_with_token(db_session, user.id, token) - assert result is True - -# Test unlocking a user's account -async def test_unlock_user_account(db_session, locked_user): - unlocked = await UserService.unlock_user_account(db_session, locked_user.id) - assert unlocked, "The account should be unlocked" - refreshed_user = await UserService.get_by_id(db_session, locked_user.id) - assert not refreshed_user.is_locked, "The user should no longer be locked" +from builtins import range +import pytest +from sqlalchemy import select +from app.dependencies import get_settings +from app.models.user_model import User +from app.services.user_service import UserService + +pytestmark = pytest.mark.asyncio + +# Test creating a user with valid data +async def test_create_user_with_valid_data(db_session, email_service): + user_data = { + "email": "valid_user@example.com", + "password": "ValidPassword123!", + } + user = await UserService.create(db_session, user_data, email_service) + assert user is not None + assert user.email == user_data["email"] + +# Test creating a user with invalid data +async def test_create_user_with_invalid_data(db_session, email_service): + user_data = { + "nickname": "", # Invalid nickname + "email": "invalidemail", # Invalid email + "password": "short", # Invalid password + } + user = await UserService.create(db_session, user_data, email_service) + assert user is None + +# Test fetching a user by ID when the user exists +async def test_get_by_id_user_exists(db_session, user): + retrieved_user = await UserService.get_by_id(db_session, user.id) + assert retrieved_user.id == user.id + +# Test fetching a user by ID when the user does not exist +async def test_get_by_id_user_does_not_exist(db_session): + non_existent_user_id = "non-existent-id" + retrieved_user = await UserService.get_by_id(db_session, non_existent_user_id) + assert retrieved_user is None + +# Test fetching a user by nickname when the user exists +async def test_get_by_nickname_user_exists(db_session, user): + retrieved_user = await UserService.get_by_nickname(db_session, user.nickname) + assert retrieved_user.nickname == user.nickname + +# Test fetching a user by nickname when the user does not exist +async def test_get_by_nickname_user_does_not_exist(db_session): + retrieved_user = await UserService.get_by_nickname(db_session, "non_existent_nickname") + assert retrieved_user is None + +# Test fetching a user by email when the user exists +async def test_get_by_email_user_exists(db_session, user): + retrieved_user = await UserService.get_by_email(db_session, user.email) + assert retrieved_user.email == user.email + +# Test fetching a user by email when the user does not exist +async def test_get_by_email_user_does_not_exist(db_session): + retrieved_user = await UserService.get_by_email(db_session, "non_existent_email@example.com") + assert retrieved_user is None + +# Test updating a user with valid data +async def test_update_user_valid_data(db_session, user): + new_email = "updated_email@example.com" + updated_user = await UserService.update(db_session, user.id, {"email": new_email}) + assert updated_user is not None + assert updated_user.email == new_email + +# Test updating a user with invalid data +async def test_update_user_invalid_data(db_session, user): + updated_user = await UserService.update(db_session, user.id, {"email": "invalidemail"}) + assert updated_user is None + +# Test deleting a user who exists +async def test_delete_user_exists(db_session, user): + deletion_success = await UserService.delete(db_session, user.id) + assert deletion_success is True + +# Test attempting to delete a user who does not exist +async def test_delete_user_does_not_exist(db_session): + non_existent_user_id = "non-existent-id" + deletion_success = await UserService.delete(db_session, non_existent_user_id) + assert deletion_success is False + +# Test listing users with pagination +async def test_list_users_with_pagination(db_session, users_with_same_role_50_users): + users_page_1 = await UserService.list_users(db_session, skip=0, limit=10) + users_page_2 = await UserService.list_users(db_session, skip=10, limit=10) + assert len(users_page_1) == 10 + assert len(users_page_2) == 10 + assert users_page_1[0].id != users_page_2[0].id + +# Test registering a user with valid data +async def test_register_user_with_valid_data(db_session, email_service): + user_data = { + "email": "register_valid_user@example.com", + "password": "RegisterValid123!", + } + user = await UserService.register_user(db_session, user_data, email_service) + assert user is not None + assert user.email == user_data["email"] + +# Test attempting to register a user with invalid data +async def test_register_user_with_invalid_data(db_session, email_service): + user_data = { + "email": "registerinvalidemail", # Invalid email + "password": "short", # Invalid password + } + user = await UserService.register_user(db_session, user_data, email_service) + assert user is None + +# Test successful user login +async def test_login_user_successful(db_session, verified_user): + user_data = { + "email": verified_user.email, + "password": "MySuperPassword$1234", + } + logged_in_user = await UserService.login_user(db_session, user_data["email"], user_data["password"]) + assert logged_in_user is not None + +# Test user login with incorrect email +async def test_login_user_incorrect_email(db_session): + user = await UserService.login_user(db_session, "nonexistentuser@noway.com", "Password123!") + assert user is None + +# Test user login with incorrect password +async def test_login_user_incorrect_password(db_session, user): + user = await UserService.login_user(db_session, user.email, "IncorrectPassword!") + assert user is None + +# Test account lock after maximum failed login attempts +async def test_account_lock_after_failed_logins(db_session, verified_user): + max_login_attempts = get_settings().max_login_attempts + for _ in range(max_login_attempts): + await UserService.login_user(db_session, verified_user.email, "wrongpassword") + + is_locked = await UserService.is_account_locked(db_session, verified_user.email) + assert is_locked, "The account should be locked after the maximum number of failed login attempts." + +# Test resetting a user's password +async def test_reset_password(db_session, user): + new_password = "NewPassword123!" + reset_success = await UserService.reset_password(db_session, user.id, new_password) + assert reset_success is True + +# Test verifying a user's email +async def test_verify_email_with_token(db_session, user): + token = "valid_token_example" # This should be set in your user setup if it depends on a real token + user.verification_token = token # Simulating setting the token in the database + await db_session.commit() + result = await UserService.verify_email_with_token(db_session, user.id, token) + assert result is True + +# Test unlocking a user's account +async def test_unlock_user_account(db_session, locked_user): + unlocked = await UserService.unlock_user_account(db_session, locked_user.id) + assert unlocked, "The account should be unlocked" + refreshed_user = await UserService.get_by_id(db_session, locked_user.id) + assert not refreshed_user.is_locked, "The user should no longer be locked"