diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..61b2261 Binary files /dev/null and b/.coverage differ diff --git a/.github/workflows/github-actions-demo.yml b/.github/workflows/github-actions-demo.yml new file mode 100644 index 0000000..51eb1f9 --- /dev/null +++ b/.github/workflows/github-actions-demo.yml @@ -0,0 +1,84 @@ +name: python_social_media_backend +on: + push: + branches: + - "dev_fastapi" + pull_request: + branches: + - "dev_fastapi" +jobs: + fastapi_job: + runs-on: ubuntu-latest + environment: + name: testing + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: ${{ secrets.DATABASE_PASSWORD }} + POSTGRES_DB: ${{ secrets.TEST_DATABASE_NAME }} + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Hello User + run: echo Hello ${{ github.actor }} + + - name: Pulling Git Repository + uses: actions/checkout@v4 + + - name: Set Up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10.x' + + - name: Upgrade Pip + run: python -m pip install --upgrade pip + + - name: Install Dependencies + run: pip install -r requirements.txt + + - name: Run Pytest with Coverage + env: + DATABASE_HOSTNAME: ${{ secrets.DATABASE_HOSTNAME }} + DATABASE_PORT: ${{ secrets.DATABASE_PORT }} + DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }} + DATABASE_NAME: ${{ secrets.DATABASE_NAME }} + TEST_DATABASE_NAME: ${{ secrets.TEST_DATABASE_NAME }} + DATABASE_NAME1: ${{ secrets.DATABASE_NAME1 }} + DATABASE_USERNAME: ${{ secrets.DATABASE_USERNAME }} + SECRET_KEY: ${{ secrets.SECRET_KEY }} + ALGORITHM: ${{ secrets.ALGORITHM }} + ACCESS_TOKEN_EXPIRE_MINUTES: ${{ secrets.ACCESS_TOKEN_EXPIRE_MINUTES }} + DATABASE_STRING: ${{ secrets.DATABASE_STRING }} + run: | + pip install pytest + pip install pytest-cov + pytest + pytest --cov=app + - name: Login into Docker + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Setup Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v3 + - name: Build and push + id: docker_build + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + builder: ${{ steps.buildx.outputs.name }} + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ secrets.DOCKER_USERNAME }}/fastapi:latest + cache-from: type=gha + cache-to: type=gha, mode=max + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5bd6df --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +venv/ +__pycache__/ +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..68fbfea --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /usr/src/app +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +CMD ["uvicorn","app.main:app","--host","0.0.0.0","--port","9099"] \ No newline at end of file diff --git a/README.md b/README.md index 1281f93..8b7409a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,71 @@ -# python_fastapi -Demonstrating Fastapi Usage +# ๐Ÿš€ Social Media (Basic) - FastAPI + +A **Social Media** built from scratch using **FastAPI**, featuring a scalable and secure backend with Github Actions CI/CD pipeline integration. + +## ๐Ÿ“Œ Features +- **๐Ÿ”— RESTful API** with FastAPI +- **๐Ÿ’พ Database**: PostgreSQL with SQLModel ORM +- **โœ… Data Validation**: Pydantic +- **๐Ÿ”„ Database Migrations**: Alembic +- **๐Ÿ” Secure Endpoints**: JWT Authentication +- **๐Ÿณ Dockerized Application** +- **๐Ÿ› ๏ธ Continuous Integration**: GitLab CI/CD Pipeline +- **๐Ÿงช Automated Testing**: Pytest + +## ๐Ÿ—๏ธ Tech Stack +- **FastAPI** - High-performance web framework +- **SQLModel** - ORM for database interactions +- **Pydantic** - Data validation and settings management +- **Alembic** - Database migration tool +- **JWT Tokens** - Secure authentication & authorization +- **PostgreSQL** - Relational database +- **Docker** - Containerized deployment +- **Pytest** - Automated API testing +- **Github Actions CI/CD** - Continuous integration and deployment + +## ๐Ÿš€ Getting Started +### 1๏ธโƒฃ Clone the Repository +```bash +git clone https://github.com/RathQd/python_fastapi.git +cd python_fastapi +``` + +### 2๏ธโƒฃ Setup Environment Variables +Create a `.env` file in the root directory and configure your database & JWT settings. +```env +DATABASE_URL= $URL$ +SECRET_KEY= $KEY$ +ALGORITHM= $ALGO$ +ACCESS_TOKEN_EXPIRE_MINUTES= $TIME_IN_MINs$ +``` + +#### ๐Ÿ–ฅ๏ธ Without Docker (Local Environment) +```bash +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +### 4๏ธโƒฃ Run Migrations +```bash +alembic upgrade head +``` + +### 5๏ธโƒฃ Run Tests +```bash +pytest already integrated with github actions CI/CD. +``` + +## ๐Ÿ“ก API Documentation +FastAPI provides interactive API docs: +- **Swagger UI**: `http://localhost:8000/docs` +- **ReDoc**: `http://localhost:8000/redoc` + +## ๐Ÿ“ฆ Deployment with Github actions CI/CD +This project integrates a **Github actions CI/CD pipeline** for automated testing and deployment. + +## ๐Ÿค Contributing +Contributions are welcome! Feel free to open issues or submit PRs. + +--- +**Star โญ the repo if you found it useful!** + diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..421e3fe --- /dev/null +++ b/alembic.ini @@ -0,0 +1,117 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = migrations + +# 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 migrations/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:migrations/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 = newline +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[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 = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +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/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..1830bfc --- /dev/null +++ b/app/config.py @@ -0,0 +1,22 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + database_hostname: str + database_port: str + database_password: str + database_name: str + database_name1: str + test_database_name: str + database_username: str + secret_key: str + algorithm: str + access_token_expire_minutes: int + database_string: str + + class Config: + env_file = ".env" + + + +settings = Settings() diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/crud/posts_crud.py b/app/crud/posts_crud.py new file mode 100644 index 0000000..75dc664 --- /dev/null +++ b/app/crud/posts_crud.py @@ -0,0 +1,43 @@ +from app.database import get_session +from app.model import UPosts, Votes, Users +from sqlmodel import select, func +from sqlalchemy.orm import joinedload + +session = next(get_session()) + +def insert_data_in_uposts_table(post: UPosts, session): + session.add(post) + session.commit() + session.refresh(post) + return post + +def get_all_post(limit, skip, search, session): + posts = session.exec(select(UPosts).filter(UPosts.title.contains(search)).limit(limit).offset(skip)).all() + return posts + +def get_all_post_with_count(limit, skip, search, session): + posts = session.exec(select(UPosts, func.count(Votes.post_id).label("vote_count")).join(Votes, UPosts.id == Votes.post_id, isouter=True).group_by(UPosts.id).filter(UPosts.title.contains(search)).limit(limit).offset(skip)).all() + posts_with_counts = [{"UPosts": post, "vote": vote_count} for post, vote_count in posts] + return posts_with_counts + +def get_post_by_id(id: int, session): + post = session.get(UPosts, id) + return post + +def update_post_by_id(id: int, upost: UPosts, session): + postTobeUpdated = session.exec(select(UPosts).where(UPosts.id == id)).first() + if postTobeUpdated: + upost.id = id + postTobeUpdated = session.merge(upost) + session.add(postTobeUpdated) + session.commit() + session.refresh(postTobeUpdated) + return postTobeUpdated + +def delete_post_by_id(id: int, session): + upost = session.exec(select(UPosts).join(Users).where(UPosts.id == id).options(joinedload(UPosts.owner))).first() + if upost: + session.delete(upost) + session.commit() + return upost + diff --git a/app/crud/users_crud.py b/app/crud/users_crud.py new file mode 100644 index 0000000..cc99190 --- /dev/null +++ b/app/crud/users_crud.py @@ -0,0 +1,54 @@ +from fastapi import HTTPException, status +from pydantic import ValidationError +from app.database import get_session +from app.model import Users +from sqlmodel import select + +def insert_data_in_users_table(user: Users, session)->Users: + try: + user_dict = {**user.model_dump()} + Users.validate_email_domain(user.email) + user.password = Users.password_hash(user.password) + session.add(user) + session.commit() + except Exception as e: + session.rollback() + raise HTTPException(status_code=status.HTTP_406_NOT_ACCEPTABLE,detail=f"Failed to Create User : Validation") + session.refresh(user) + return user + +def get_all_user(session)->Users: + users = session.exec(select(Users)).all() + return users + +def get_user_by_id(id: int, session): + user = session.get(Users, id) + if user: + return user + else: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"User with {id} not found") + +def get_user_by_email(email: str, session): + user = session.exec(select(Users).where(Users.email == email)).first() + if user: + return user + else: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"User Not found in the System") + +def update_user_by_id(id: int, user: Users, session): + usertobeupdated = session.exec(select(Users).where(Users.userid == id)).first() + if usertobeupdated: + user.userid = id + usertobeupdated = session.merge(user) + session.add(usertobeupdated) + session.commit() + session.refresh(usertobeupdated) + return usertobeupdated + +def delete_user_by_id(id: int, session): + user = session.get(Users, id) + if user: + session.delete(user) + session.commit() + return user + diff --git a/app/crud/votes_crud.py b/app/crud/votes_crud.py new file mode 100644 index 0000000..80122d7 --- /dev/null +++ b/app/crud/votes_crud.py @@ -0,0 +1,23 @@ +from fastapi import HTTPException, status +from pydantic import ValidationError +from app.database import get_session +from app.model import Vote, Votes +from sqlmodel import select + +def is_vote_exist(vote: Vote, userid, session): + vote_found = session.exec(select(Votes).filter(Votes.post_id == vote.post_id, Votes.user_id == userid)).first() + return vote_found + +def add_vote(vote: Vote, userid, session): + vote = Votes(post_id=vote.post_id, user_id=userid) + session.add(vote) + session.commit() + return {"Message":"Added Vote"} + +def delete_vote(vote: Vote, userid, session): + vote = session.exec(select(Votes).filter(Votes.post_id == vote.post_id, Votes.user_id == userid)).first() + session.delete(vote) + session.commit() + return {"Message":"Deleted Vote"} + + diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..298c8f8 --- /dev/null +++ b/app/database.py @@ -0,0 +1,46 @@ +from sqlmodel import create_engine, SQLModel, Session +from .config import settings + +POSTGRESS_SQL_DATABASE_URL = f"postgresql://{settings.database_username}:{settings.database_password}@{settings.database_hostname}/{settings.database_name1}" +TEST_POSTGRESS_SQL_DATABASE_URL = f"postgresql://{settings.database_username}:{settings.database_password}@{settings.database_hostname}/{settings.test_database_name}" + +# add echo = true if you like to log create query +SQL_DATABASE_URL = POSTGRESS_SQL_DATABASE_URL +# engine = create_engine(SQL_DATABASE_URL) #Dev Env +engine = create_engine(f"{settings.database_string}",echo = True) + +def set_test_database(): + print("Setting Up test databse") + global engine + # engine = create_engine(f"{settings.test_database_string}", echo = True) + engine = create_engine(TEST_POSTGRESS_SQL_DATABASE_URL) + +def get_override_session(): + with Session(engine) as session: + try: + yield session + finally: + session.close() + +def create_database_and_tables(): + SQLModel.metadata.create_all(bind=engine) + print("database Created") + print("database Created at :", engine.url) + return "Database Connected" + +def close_connection(): + print("Database Closed") + engine.dispose() + return "Database Disconnected" + +def drop_database_and_tables(): + print("Database Dropped") + SQLModel.metadata.drop_all(engine) + return "Database Droped" + +def get_session() -> Session: # type: ignore + with Session(engine) as session: + try: + yield session + finally: + session.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..c97c5e9 --- /dev/null +++ b/app/main.py @@ -0,0 +1,121 @@ +from fastapi import FastAPI +from contextlib import asynccontextmanager +from app.routers import posts, users, auth, votes +from fastapi.middleware.cors import CORSMiddleware +import uvicorn +from app.config import settings +from app.database import create_database_and_tables, drop_database_and_tables, close_connection +from .model import * + +@asynccontextmanager +async def lifespan(app: FastAPI): + # app.state.db = drop_database_and_tables() + app.state.db = create_database_and_tables() + print(app.state.db) + + yield + + app.state.db = close_connection() + print(app.state.db) + + +app = FastAPI(lifespan=lifespan) + +origins = ["*"] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +app.include_router(posts.router, prefix="/uposts", tags=["uposts"]) +app.include_router(users.router, prefix="/users", tags=["users"]) +app.include_router(auth.router, prefix="/auth", tags=["authentication"]) +app.include_router(votes.router, prefix="/vote", tags=["votes"]) + + + +@app.get("/") +def root(): + return {"Message": "Welcome to FastAPI, My First Web APP"} + + +if __name__ == "__main__": + port = settings.database_port + uvicorn.run(app, host="0.0.0.0", port=port) + + +# class Post(BaseModel): +# tittle: str +# content: str +# published: bool = True + +# while True: +# try: +# conn = psycopg2.connect(host='localhost',database='fastapi', user='postgres', password='root', cursor_factory=RealDictCursor) +# cursor = conn.cursor() +# print("Connected to Database") +# break +# except Exception as er: +# print("Connection Failed") +# print("Error: ", er) +# time.sleep(3) + +# my_post = [{"tittle":"tittle of the post", "content":"content of the post", "id":1}, +# {"tittle":"tittle of the post", "content":"content of the post", "id":2} +# ] +# @app.get('/') +# def root(): +# return "Hello User" + +# @app.post('/schema') +# def create_schema(): +# create_database_and_table() +# return {"Tables Created"} + +# @app.get('/posts') +# def get_list_of_all_post(): +# cursor.execute("SELECT * from posts") +# posts = cursor.fetchall() +# return {"data":posts} + +# @app.post('/posts', status_code=status.HTTP_201_CREATED) +# def create_post(post: Post): +# print(post.model_dump()) +# cursor.execute("INSERT INTO posts(tittle, content, published) VALUES (%s, %s, %s) RETURNING *",(post.tittle, post.content, post.published)) +# new_post = cursor.fetchone() +# conn.commit() +# return {"message": new_post} + +# @app.get('/posts/{id}') +# def get_post(id: int, response: Response): +# print() +# cursor.execute("SELECT * from posts WHERE id = %s", (id,)) +# post = cursor.fetchone() +# if not post: +# raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail= f"post with {id} not found") +# return {"Message":f"This is your post with {id}", "post":post} + +# @app.delete('/posts/delete/{id}') +# def delete_post(id: int): +# cursor.execute("DELETE from posts where id = %s returning *", (str(id))) +# deleted_post = cursor.fetchone() +# conn.commit() +# if not deleted_post: +# raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,detail= f"post with {id} not found") +# return {"message": f"post with {id} deleted", "post": deleted_post} + +# @app.put('/posts/update/{id}') +# def update_post(id: int, post: Post): +# cursor.execute("update posts set tittle = %s, content = %s, published = %s where id = %s returning *", (str(post.tittle), post.content, post.published,str(id))) +# updated_post = cursor.fetchone() +# conn.commit() + +# if updated_post == None: +# raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"post with {id} does not exist") +# return {"data": updated_post} + diff --git a/app/model.py b/app/model.py new file mode 100644 index 0000000..00db9df --- /dev/null +++ b/app/model.py @@ -0,0 +1,96 @@ +import datetime +import bcrypt +from pydantic import EmailStr, field_validator +from typing import List, Optional +from sqlalchemy import ForeignKey +from sqlmodel import Field, SQLModel, Relationship +from .database import engine +from enum import Enum + +class Users(SQLModel, table=True): + userid: Optional[int] | None = Field(default=None, primary_key=True) + email: EmailStr = Field(sa_column_kwargs={"unique": True}) + password: str + # phone: str + create_dtm: Optional[datetime.datetime] = Field(default_factory=datetime.datetime.now) + posts: list["UPosts"] = Relationship(back_populates="owner", sa_relationship_kwargs={"cascade":"all, delete-orphan"}) + votes: list["Votes"] = Relationship(back_populates="user", sa_relationship_kwargs={"cascade":"all, delete-orphan"}) + + + @field_validator("email", mode="before") + def validate_email_domain(cls, value): + allowed_domains = ["gmail.com", "yahoo.com"] + domain = value.split('@')[-1] + if domain not in allowed_domains: + raise ValueError(f"Invalid email domain: {domain}. Allowed domains: {', '.join(allowed_domains)}") + return value + + @field_validator("password", mode = "before") + def password_hash(cls, password): + if password: + hashedpass = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) + return hashedpass.decode('utf-8') + return password + + def verify_password(self, password: str)->bool: + return bcrypt.checkpw(password.encode('utf-8'), self.password.encode('utf-8')) + +class UPosts(SQLModel, table=True): + id: Optional[int] | None = Field(default= None, primary_key = True) + title: str + content: str + userid: int = Field(foreign_key="users.userid", nullable=False) + create_dtm: Optional[datetime.datetime] = Field(default_factory=datetime.datetime.now) + owner: Optional[Users] = Relationship(back_populates="posts") + votes: list["Votes"] = Relationship(back_populates="post", sa_relationship_kwargs={"cascade":"all, delete-orphan"}) + +class UserOut(SQLModel): + userid: int + email: EmailStr + create_dtm: datetime.datetime + +class UserIn(SQLModel): + email: EmailStr + password: str + +class UPostOut(SQLModel): + id: int + title: str + content: str + userid: int + create_dtm: datetime.datetime + owner: Optional[UserOut] + +class UPostCreate(SQLModel): + title: str + content: str + +class UserLogin(SQLModel): + email: EmailStr + password: str + +class Token(SQLModel): + access_token: str + token_type: str + +class TokenData(SQLModel): + email: Optional[EmailStr] = None + id: Optional[int] = None + +class Votes(SQLModel, table = True): + user_id: Optional[int] = Field(default= None, primary_key = True, foreign_key="users.userid") + post_id: Optional[int] = Field(default= None, primary_key = True, foreign_key="uposts.id") + user: Optional[Users] = Relationship(back_populates="votes") + post: Optional[UPosts] = Relationship(back_populates="votes") + +class VoteDirection(str,Enum): + DOWNVOTE = "DOWNVOTE" # type: ignore + UPVOTE = "UPVOTE" # type: ignore + +class Vote(SQLModel): + post_id: int + dir: VoteDirection + +class UPostswithCount(SQLModel): + UPosts: UPosts + vote: int diff --git a/app/oauth2.py b/app/oauth2.py new file mode 100644 index 0000000..fce2f29 --- /dev/null +++ b/app/oauth2.py @@ -0,0 +1,51 @@ +import jwt +from datetime import datetime, timedelta, timezone +from app.model import TokenData +from fastapi import status, Depends, HTTPException +from fastapi.security import OAuth2PasswordBearer +from app.crud.users_crud import * +from .config import settings + + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl='auth/login') + + +SECRET_KEY = f"{settings.secret_key}" +ALGORITHM = f"{settings.algorithm}" +ACCESS_TOKEN_EXPIRE_MINUTES = settings.access_token_expire_minutes + + +def create_jwt_token(data: dict): + encoded_data = data.copy() + expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + encoded_data.update({"exp": expire.timestamp()}) + token =jwt.encode(encoded_data, SECRET_KEY, ALGORITHM) + return token + +def verify_jwt_token(token: str, credentials_exception): + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + email : str = payload.get("email") + id: int = payload.get("userid") + if email is None: + raise credentials_exception + if id is None: + raise credentials_exception + token_data = TokenData(email=email,id=id) + + except jwt.InvalidTokenError: + raise credentials_exception + + except jwt.ExpiredSignatureError: + raise credentials_exception + return token_data + +def get_current_user(token: str = Depends(oauth2_scheme)): + credentials_exception = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Could not validate Credentials", headers={"WWW-Authenticate":"Bearer"}) + token_data = verify_jwt_token(token, credentials_exception) + return verify_jwt_token(token, credentials_exception) + + + + + diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..94d4609 --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, HTTPException, status, Depends +from fastapi.security.oauth2 import OAuth2PasswordRequestForm +from app.model import UserLogin, Token +from app.crud.users_crud import get_user_by_email +from app.util import * +from app.oauth2 import create_jwt_token +from app.database import get_session +from sqlmodel import Session + +router = APIRouter() + +@router.post("/login", status_code=status.HTTP_200_OK, response_model=Token) +def login(user_credentials: OAuth2PasswordRequestForm = Depends(), session: Session = Depends(get_session) ): + user = get_user_by_email(user_credentials.username, session) + if not user: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,detail=f"User Not Found") + access_token = create_jwt_token(data = {"email": user.email, "userid": user.userid}) + if not verify(user_credentials.password, user.password): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Invalid Password or Email Id") + return {"access_token": access_token, "token_type": "bearer"} + + diff --git a/app/routers/posts.py b/app/routers/posts.py new file mode 100644 index 0000000..d221d71 --- /dev/null +++ b/app/routers/posts.py @@ -0,0 +1,72 @@ +from typing import List, Optional +from fastapi import APIRouter, HTTPException, status, Response, Depends +from app.crud.posts_crud import * +from app.model import UPostOut, TokenData, UPostswithCount, UPostCreate +from app.oauth2 import get_current_user +from sqlmodel import Session +from app.database import get_session + + +router = APIRouter() + +@router.get('/', status_code=status.HTTP_200_OK, response_model=List[UPostOut], responses={ + 404: {"description": "Posts not found"}, + 500: {"description": "Internal server error"} + }) +def get_list_of_post(current_user:TokenData = Depends(get_current_user), session: Session = Depends(get_session), limit: int = 5, skip: int = 0, search: Optional[str] = "")->List[UPostOut]: + posts = get_all_post(limit, skip, search, session) + return posts + +@router.get('/count', status_code=status.HTTP_200_OK, response_model=List[UPostswithCount], responses={ + 404: {"description": "Posts not found"}, + 500: {"description": "Internal server error"} + }) +def get_list_of_post_with_count(current_user:TokenData = Depends(get_current_user),session: Session = Depends(get_session) , limit: int = 5, skip: int = 0, search: Optional[str] = "")->List[UPostswithCount]: + posts = get_all_post_with_count(limit, skip, search, session) + return posts + +@router.get('/{id}', status_code=status.HTTP_200_OK, response_model=UPostOut, responses={ + 404: {"description": "Post not found"}, + 500: {"description": "Internal server error"} + }) +def get_post(id: int, response: Response, current_user:TokenData = Depends(get_current_user), session: Session = Depends(get_session)): + post = get_post_by_id(id, session) + if not post: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"post with {id} does not exist in UPosts") + if current_user.id != post.userid: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"User not Authorized to get the post") + return post + +@router.post('/', status_code=status.HTTP_201_CREATED, response_model=UPostOut, responses={ + 500: {"description": "Internal server error"} + }) +def create_post(post: UPostCreate, current_user:TokenData = Depends(get_current_user), session: Session = Depends(get_session)): + post = UPosts(title=post.title, content=post.content) + post.userid = current_user.id + post = insert_data_in_uposts_table(post, session) + return post + +@router.put('/{id}', status_code=status.HTTP_200_OK, response_model=UPostOut, responses={ + 404: {"description": "Post not found"}, + 500: {"description": "Internal server error"} + }) +def update_post(id: int, post: UPostCreate, response: Response, current_user:TokenData = Depends(get_current_user), session: Session = Depends(get_session)): + post = UPosts(title=post.title, content=post.content) + post = update_post_by_id(id, post, session) + if not post: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"post with {id} does not exist in UPosts") + if current_user.id != post.userid: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"User not Authorized to get the post") + return post + +@router.delete('/{id}', status_code=status.HTTP_200_OK, response_model=UPostOut, responses={ + 404: {"description": "Post not found"}, + 500: {"description": "Internal server error"} + }) +def delete_post(id: int, response: Response, current_user:TokenData = Depends(get_current_user), session: Session = Depends(get_session)): + post = delete_post_by_id(id, session) + if not post: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"post with {id} does not exist in UPosts") + if current_user.id != post.userid: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"User not Authorized to get the post") + return post diff --git a/app/routers/users.py b/app/routers/users.py new file mode 100644 index 0000000..61b846f --- /dev/null +++ b/app/routers/users.py @@ -0,0 +1,70 @@ +from typing import List +from fastapi import APIRouter, status, HTTPException, Response, Depends +from app.crud.users_crud import * +from app.model import UserOut, UserIn +from app.database import get_session +from sqlmodel import Session + +router = APIRouter() + +@router.get("/", status_code=status.HTTP_201_CREATED, response_model=List[UserOut], responses={ + 404: {"description": "User not found"}, + 500: {"description": "Internal server error"} + }) +def get_list_of_users(session: Session = Depends(get_session))->List[UserOut]: + users = get_all_user(session) + return users + +@router.get('/by-id/{id}', status_code=status.HTTP_200_OK, response_model=UserOut, responses={ + 404: {"description": "User not found"}, + 500: {"description": "Internal server error"} + }) +def get_user_with_id(id: int, response: Response, session: Session = Depends(get_session)): + user = get_user_by_id(id, session) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"user with {id} does not exist in Users") + return user + +@router.get('/by-email/{email}', status_code=status.HTTP_200_OK, response_model=UserOut, responses={ + 404: {"description": "User not found"}, + 500: {"description": "Internal server error"} + }) +def get_user_with_email(email: str, response: Response, session: Session = Depends(get_session)): + user = get_user_by_email(email, session) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"user with {email} does not exist in Users") + return user + +@router.post('/', status_code=status.HTTP_201_CREATED, response_model=UserOut, responses={ + 500: {"description": "Internal server error"} + }) +def create_user(user: UserIn, session: Session = Depends(get_session))->UserOut: + try: + user = Users(email=user.email, password=user.password) + user = insert_data_in_users_table(user, session) + except HTTPException as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,detail=f"Validation Error {e}") + except Exception as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,detail=f"Error {str(e.args)}") + return user + +@router.put('/{id}', status_code=status.HTTP_200_OK, response_model=UserOut, responses={ + 404: {"description": "User not found"}, + 500: {"description": "Internal server error"} + }) +def update_user(id: int, user: UserIn, response: Response, session: Session = Depends(get_session)): + user = Users(email=user.email, password=user.password) + user = update_user_by_id(id, user, session) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"user with {id} does not exist in Users") + return user + +@router.delete('/{id}', status_code=status.HTTP_200_OK, response_model=UserOut, responses={ + 404: {"description": "User not found"}, + 500: {"description": "Internal server error"} + }) +def delete_user(id: int, response: Response, session: Session = Depends(get_session)): + user = delete_user_by_id(id, session) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"user with {id} does not exist in Users") + return user diff --git a/app/routers/votes.py b/app/routers/votes.py new file mode 100644 index 0000000..65992c3 --- /dev/null +++ b/app/routers/votes.py @@ -0,0 +1,22 @@ +from typing import List +from fastapi import APIRouter, Depends, status, HTTPException, Response +from app.crud.votes_crud import * +from app.model import Vote, TokenData +from app.oauth2 import get_current_user +from sqlmodel import Session +from app.database import get_session + +router = APIRouter() + +@router.post("/", status_code=status.HTTP_201_CREATED) +def vote(vote: Vote, current_user:TokenData = Depends(get_current_user), session: Session = Depends(get_session)): + vote_found = is_vote_exist(vote, current_user.id, session) + if(vote.dir.value == "UPVOTE"): + if vote_found: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"User {current_user.id} has Already voted on Post with id {vote.post_id}") + return add_vote(vote, current_user.id, session) + else: + if not vote_found: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"User {current_user.id} has not voted on Post with id {vote.post_id}") + return delete_vote(vote, current_user.id, session) + diff --git a/app/util.py b/app/util.py new file mode 100644 index 0000000..228cfa4 --- /dev/null +++ b/app/util.py @@ -0,0 +1,9 @@ +from passlib.context import CryptContext +pass_context = CryptContext(schemes=["bcrypt"], deprecated = "auto") + + +def hash(password: str): + return pass_context.hash(password) + +def verify(row_password, hashed_password): + return pass_context.verify(row_password, hashed_password) \ No newline at end of file diff --git a/database.db b/database.db new file mode 100644 index 0000000..aa0e89a Binary files /dev/null and b/database.db differ diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..229b885 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,92 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from alembic import context +from sqlmodel import SQLModel +from app.model import * +from app.config import settings + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config +config.set_main_option("sqlalchemy.url",f'postgresql+psycopg2://{settings.database_username}:{settings.database_password}@{settings.database_hostname}/{settings.database_name}') +# config.set_main_option("sqlalchemy.url",f'{settings.database_string}') + + +# 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 = SQLModel.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 process_revision_directives(context, revision, directives): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + else: + script.imports.add("import sqlmodel") + + +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"}, + process_revision_directives=process_revision_directives + ) + + 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, + process_revision_directives=process_revision_directives + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +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"} diff --git a/migrations/versions/6b78e5356a6f_test_migrations.py b/migrations/versions/6b78e5356a6f_test_migrations.py new file mode 100644 index 0000000..bc4d7c1 --- /dev/null +++ b/migrations/versions/6b78e5356a6f_test_migrations.py @@ -0,0 +1,55 @@ +"""Test Migrations + +Revision ID: 6b78e5356a6f +Revises: +Create Date: 2025-01-07 12:06:00.908088 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# revision identifiers, used by Alembic. +revision: str = '6b78e5356a6f' +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('userid', sa.Integer(), nullable=False), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('password', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('create_dtm', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('userid'), + sa.UniqueConstraint('email') + ) + op.create_table('uposts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('content', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('userid', sa.Integer(), nullable=False), + sa.Column('create_dtm', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['userid'], ['users.userid'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('votes', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('post_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['post_id'], ['uposts.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.userid'], ), + sa.PrimaryKeyConstraint('user_id', 'post_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('votes') + op.drop_table('uposts') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/migrations/versions/9538b4ec1aaf_adding_phone_column.py b/migrations/versions/9538b4ec1aaf_adding_phone_column.py new file mode 100644 index 0000000..bd5c010 --- /dev/null +++ b/migrations/versions/9538b4ec1aaf_adding_phone_column.py @@ -0,0 +1,30 @@ +"""adding phone column + +Revision ID: 9538b4ec1aaf +Revises: 6b78e5356a6f +Create Date: 2025-01-07 12:12:05.595441 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# revision identifiers, used by Alembic. +revision: str = '9538b4ec1aaf' +down_revision: Union[str, None] = '6b78e5356a6f' +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.add_column('users', sa.Column('phone', sqlmodel.sql.sqltypes.AutoString(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'phone') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..140abcf Binary files /dev/null and b/requirements.txt differ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7ee799c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,36 @@ +import pytest +from fastapi.testclient import TestClient +from app.main import app +from app.model import UserIn +from app.database import create_database_and_tables, drop_database_and_tables, get_session, close_connection, get_override_session, set_test_database +from app.oauth2 import create_jwt_token + +@pytest.fixture(scope="module") +def session(): + set_test_database() + drop_database_and_tables() + create_database_and_tables() + try: + yield get_override_session() + finally: + close_connection() + +@pytest.fixture(scope="module") +def client(session): + app.dependency_overrides[get_session] = get_override_session + yield TestClient(app) + +@pytest.fixture(scope="module") +def create_user(client): + user_data = UserIn(email="user@gmail.com", password="user") + response = client.post("/users/", json=user_data.model_dump()) + assert response.status_code == 201, f"Failed to create user: {response.json()}" + +@pytest.fixture(scope="module") +def create_valid_login_token(): + token = create_jwt_token(data={"email":"user1@yahoo.com", "userid": 2}) + return token + + + + diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..d5c5079 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,140 @@ +from app.model import UserOut, UserIn, Token, UPostCreate, UPostOut, Vote, UPostswithCount + +def test_root(client): + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"Message": "Welcome to FastAPI, My First Web APP"} + +def test_login_user(client, create_user): + response = client.post("/auth/login", data={"username": "user@gmail.com", "password": "user"}) + login_response = Token(**response.json()) + assert response.status_code == 200, f"Login Failed: {response.json()}" + +def test_create_user(client): + response = client.post("/users/",json=UserIn(email="user1@yahoo.com", password="user1").model_dump()) + UserOut(**response.json()) + assert response.status_code == 201 + +def test_get_user_by_id(client): + response = client.get("/users/by-id/1") + user = UserOut(**response.json()) + assert user.userid == 1 + assert user.email == "user@gmail.com" + +def test_get_user_not_exist_by_id(client): + response = client.get("/users/by-id/9999999999") + assert response.status_code == 404 + +def test_get_user_by_email(client): + response = client.get("/users/by-email/user@gmail.com") + user = UserOut(**response.json()) + assert user.userid == 1 + assert user.email == "user@gmail.com" + +def test_get_user_not_exist_by_email(client): + response = client.get("/users/by-email/user546745@gmail.com") + assert response.status_code == 404 + +def test_create_duplicate_user(client): + response = client.post("/users/", json=UserIn(email="user@gmail.com", password="test1").model_dump()) + assert response.json() == {'detail': 'Validation Error 406: Failed to Create User : Validation'} + assert response.status_code == 400 + +def test_update_user(client): + response = client.put("/users/1",json=UserIn(email="updateduser@yahoo.com", password="updateduser").model_dump()) + UserOut(**response.json()) + assert response.status_code == 200 + +def test_update_non_existing_user(client): + response = client.put("/users/9999999999",json=UserIn(email="updateduser@yahoo.com", password="updateduser").model_dump()) + assert response.status_code == 404 + +def test_delete_user(client): + response = client.delete("/users/1") + UserOut(**response.json()) + assert response.status_code == 200 + +def test_delete_non_existing_user(client): + response = client.delete("/users/9999999999") + assert response.status_code == 404 + +def test_list_of_users(client): + client.post("/users/",json=UserIn(email="test100@yahoo.com", password="test").model_dump()) + client.post("/users/",json=UserIn(email="test101@yahoo.com", password="test").model_dump()) + client.post("/users/",json=UserIn(email="test102@yahoo.com", password="test").model_dump()) + client.post("/users/",json=UserIn(email="test103@yahoo.com", password="test").model_dump()) + response = client.get("/users/") + users = [UserOut(**userdata) for userdata in response.json()] + assert len(users) == 5 + +def test_create_post(client, create_valid_login_token): + response = client.post("/uposts/", json=UPostCreate(title="my test post 1", content="my test content 1").model_dump(), headers={"Authorization": f"Bearer {create_valid_login_token}"} ) + UPostOut(**response.json()) + assert response.status_code == 201 + +def test_get_post(client, create_valid_login_token): + response = client.get("/uposts/1", headers={"Authorization": f"Bearer {create_valid_login_token}"} ) + UPostOut(**response.json()) + assert response.status_code == 200 + +def test_get_non_existing_post(client, create_valid_login_token): + response = client.get("/uposts/9999999999", headers={"Authorization": f"Bearer {create_valid_login_token}"} ) + assert response.status_code == 404 + +def test_update_post(client, create_valid_login_token): + response = client.put("/uposts/1", json=UPostCreate(title="my test updated post 1", content="my test udpated content 1").model_dump(), headers={"Authorization": f"Bearer {create_valid_login_token}"} ) + UPostOut(**response.json()) + assert response.status_code == 200 + +def test_update_non_existing_post(client, create_valid_login_token): + response = client.put("/uposts/9999999999", json=UPostCreate(title="my test updated post 1", content="my test udpated content 1").model_dump(), headers={"Authorization": f"Bearer {create_valid_login_token}"} ) + assert response.status_code == 404 + +def test_delete_post(client, create_valid_login_token): + response = client.delete("/uposts/1", headers={"Authorization": f"Bearer {create_valid_login_token}"} ) + assert response.status_code == 200 + +def test_delete_non_existing_post(client, create_valid_login_token): + response = client.delete("/uposts/9999999999", headers={"Authorization": f"Bearer {create_valid_login_token}"} ) + assert response.status_code == 404 + +def test_list_of_posts(client, create_valid_login_token): + response = client.post("/uposts/", json=UPostCreate(title="my test post 1", content="my test content 1").model_dump(), headers={"Authorization": f"Bearer {create_valid_login_token}"} ) + response = client.post("/uposts/", json=UPostCreate(title="my test post 2", content="my test content 2").model_dump(), headers={"Authorization": f"Bearer {create_valid_login_token}"} ) + response = client.post("/uposts/", json=UPostCreate(title="my test post 3", content="my test content 3").model_dump(), headers={"Authorization": f"Bearer {create_valid_login_token}"} ) + response = client.post("/uposts/", json=UPostCreate(title="my test post 4", content="my test content 4").model_dump(), headers={"Authorization": f"Bearer {create_valid_login_token}"} ) + response = client.post("/uposts/", json=UPostCreate(title="my test post 5", content="my test content 5").model_dump(), headers={"Authorization": f"Bearer {create_valid_login_token}"} ) + response = client.get("/uposts/", headers={"Authorization": f"Bearer {create_valid_login_token}"}) + post_response = response.json() + posts = [UPostOut(**post) for post in post_response] + assert len(posts) == 5 + +def test_upvote(client, create_valid_login_token): + response = client.post("/vote/", json= Vote(post_id=5, dir="UPVOTE").model_dump(), headers={"Authorization": f"Bearer {create_valid_login_token}"}) + assert response.json() == {"Message":"Added Vote"} + assert response.status_code == 201 + +def test_upvote_already_provided(client, create_valid_login_token): + response = client.post("/vote/", json= Vote(post_id=5, dir="UPVOTE").model_dump(), headers={"Authorization": f"Bearer {create_valid_login_token}"}) + assert response.json() == {'detail': 'User 2 has Already voted on Post with id 5'} + assert response.status_code == 409 + +def test_downvote(client, create_valid_login_token): + response = client.post("/vote/", json= Vote(post_id=5, dir="DOWNVOTE").model_dump(), headers={"Authorization": f"Bearer {create_valid_login_token}"}) + assert response.json() == {"Message":"Deleted Vote"} + assert response.status_code == 201 + +def test_downvote_with_no_upvote(client, create_valid_login_token): + response = client.post("/vote/", json= Vote(post_id=4, dir="DOWNVOTE").model_dump(), headers={"Authorization": f"Bearer {create_valid_login_token}"}) + assert response.json() == {'detail': 'User 2 has not voted on Post with id 4'} + assert response.status_code == 404 + +def test_list_of_posts_with_count(client, create_valid_login_token): + response = client.get("/uposts/count", headers={"Authorization": f"Bearer {create_valid_login_token}"} ) + posts = [UPostswithCount(**post) for post in response.json()] + assert len(posts) == 5 + + + + +