diff --git a/README.md b/README.md index e8acfba..49280c3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,139 @@ -# FastAPI Application +# Notes API -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A RESTful API for managing notes with user authentication, collections/folders, and advanced filtering capabilities. + +## Features + +- User authentication (sign up, login, logout) +- CRUD operations for notes +- Group notes into collections/folders +- Sort notes by various criteria (created date, updated date, title) +- Search notes by title and content +- Filter notes by collection, archived status, and date range + +## Tech Stack + +- **Backend**: FastAPI +- **Database**: SQLite with SQLAlchemy ORM +- **Authentication**: JWT tokens with OAuth2 +- **Migrations**: Alembic + +## Project Structure + +``` +. +├── alembic/ # Database migrations +├── app/ +│ ├── api/ # API endpoints +│ │ └── v1/ # API version 1 +│ ├── core/ # Core application settings +│ ├── crud/ # Database CRUD operations +│ ├── db/ # Database setup +│ ├── models/ # SQLAlchemy models +│ └── schemas/ # Pydantic schemas +├── storage/ +│ └── db/ # SQLite database storage +├── .env # Environment variables +├── alembic.ini # Alembic configuration +├── main.py # Application entry point +└── requirements.txt # Project dependencies +``` + +## Installation + +1. Clone the repository: + +```bash +git clone +cd notes-api +``` + +2. Install dependencies: + +```bash +pip install -r requirements.txt +``` + +3. Set up environment variables: + +Create a `.env` file in the root directory with the following variables: + +``` +SECRET_KEY=your-secret-key +CORS_ORIGINS=http://localhost:3000,http://localhost:8080 +``` + +4. Run database migrations: + +```bash +alembic upgrade head +``` + +5. Run the application: + +```bash +uvicorn main:app --reload +``` + +The API will be available at http://localhost:8000. + +## API Documentation + +Once the application is running, you can access the interactive API documentation at: + +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## API Endpoints + +### Authentication + +- `POST /api/v1/auth/register` - Register a new user +- `POST /api/v1/auth/login` - Log in and get access token +- `GET /api/v1/auth/me` - Get current user information + +### Users + +- `GET /api/v1/users/` - List all users +- `GET /api/v1/users/{user_id}` - Get user by ID +- `GET /api/v1/users/me` - Get current user +- `PUT /api/v1/users/me` - Update current user + +### Notes + +- `GET /api/v1/notes/` - List notes with various filtering and sorting options +- `POST /api/v1/notes/` - Create a new note +- `GET /api/v1/notes/{id}` - Get a note by ID +- `PUT /api/v1/notes/{id}` - Update a note +- `DELETE /api/v1/notes/{id}` - Delete a note + +### Collections + +- `GET /api/v1/collections/` - List all collections +- `POST /api/v1/collections/` - Create a new collection +- `GET /api/v1/collections/{id}` - Get a collection by ID +- `PUT /api/v1/collections/{id}` - Update a collection +- `DELETE /api/v1/collections/{id}` - Delete a collection +- `GET /api/v1/collections/{id}/notes` - Get all notes in a collection + +### Health Check + +- `GET /health` - Check API health status + +## Notes API Query Parameters + +The Notes API (`GET /api/v1/notes/`) supports the following query parameters for filtering and sorting: + +- `skip`: Number of items to skip (pagination) +- `limit`: Maximum number of items to return (pagination) +- `sort_by`: Field to sort by (`created_at`, `updated_at`, `title`) +- `sort_order`: Sort order (`asc`, `desc`) +- `search`: Search term for title and content +- `archived`: Filter by archived status (`true`, `false`) +- `collection_id`: Filter by collection ID +- `start_date`: Filter notes created after this date +- `end_date`: Filter notes created before this date + +## License + +MIT \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..e1f69e4 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,107 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration files +# file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(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-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + + +[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 + +# 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 \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..edf81d4 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,87 @@ +import os +import sys +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# Add the project directory to the python path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +# Import models +from app.db.session import 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 +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: + is_sqlite = connection.dialect.name == 'sqlite' + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=is_sqlite # Key configuration for SQLite + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..3cf5352 --- /dev/null +++ b/alembic/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"} \ No newline at end of file diff --git a/alembic/versions/0001_initial_schema.py b/alembic/versions/0001_initial_schema.py new file mode 100644 index 0000000..d5bf1ad --- /dev/null +++ b/alembic/versions/0001_initial_schema.py @@ -0,0 +1,89 @@ +"""Initial schema + +Revision ID: 0001 +Revises: +Create Date: 2023-06-01 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0001' +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: + # Create users table + op.create_table( + 'users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('username', sa.String(), nullable=False), + sa.Column('hashed_password', sa.String(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True) + + # Create collections table + op.create_table( + 'collections', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=True), + sa.Column('description', sa.String(), nullable=True), + sa.Column('owner_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_collections_id'), 'collections', ['id'], unique=False) + op.create_index(op.f('ix_collections_name'), 'collections', ['name'], unique=False) + + # Create notes table + op.create_table( + 'notes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(), nullable=True), + sa.Column('content', sa.Text(), nullable=True), + sa.Column('is_archived', sa.Boolean(), nullable=True), + sa.Column('owner_id', sa.Integer(), nullable=True), + sa.Column('collection_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['collection_id'], ['collections.id'], ), + sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_notes_created_at'), 'notes', ['created_at'], unique=False) + op.create_index(op.f('ix_notes_id'), 'notes', ['id'], unique=False) + op.create_index(op.f('ix_notes_title'), 'notes', ['title'], unique=False) + + +def downgrade() -> None: + # Drop notes table + op.drop_index(op.f('ix_notes_title'), table_name='notes') + op.drop_index(op.f('ix_notes_id'), table_name='notes') + op.drop_index(op.f('ix_notes_created_at'), table_name='notes') + op.drop_table('notes') + + # Drop collections table + op.drop_index(op.f('ix_collections_name'), table_name='collections') + op.drop_index(op.f('ix_collections_id'), table_name='collections') + op.drop_table('collections') + + # Drop users table + op.drop_index(op.f('ix_users_username'), table_name='users') + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..030efeb --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,53 @@ +from collections.abc import Generator + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app import crud +from app.core.config import settings +from app.core.security import ALGORITHM +from app.db.session import SessionLocal +from app.models.user import User +from app.schemas.auth import TokenPayload + +oauth2_scheme = OAuth2PasswordBearer( + tokenUrl=f"{settings.API_V1_STR}/auth/login" +) + + +def get_db() -> Generator: + try: + db = SessionLocal() + yield db + finally: + db.close() + + +def get_current_user( + db: Session = Depends(get_db), token: str = Depends(oauth2_scheme) +) -> User: + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[ALGORITHM] + ) + token_data = TokenPayload(**payload) + except (JWTError, ValidationError): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Could not validate credentials", + ) + user = crud.get(db, user_id=token_data.sub) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +def get_current_active_user( + current_user: User = Depends(get_current_user), +) -> User: + if not crud.is_active(current_user): + raise HTTPException(status_code=400, detail="Inactive user") + return current_user diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/api.py b/app/api/v1/api.py new file mode 100644 index 0000000..be5dd3b --- /dev/null +++ b/app/api/v1/api.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +from app.api.v1 import auth, collections, notes, users + +api_router = APIRouter(prefix="/api/v1") + +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(notes.router, prefix="/notes", tags=["notes"]) +api_router.include_router(collections.router, prefix="/collections", tags=["collections"]) diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py new file mode 100644 index 0000000..81435b9 --- /dev/null +++ b/app/api/v1/auth.py @@ -0,0 +1,75 @@ +from datetime import timedelta +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app import crud +from app.api import deps +from app.core import security +from app.core.config import settings +from app.schemas.auth import Token, User, UserCreate + +router = APIRouter() + + +@router.post("/login", response_model=Token) +def login_access_token( + db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends() +) -> Any: + """ + OAuth2 compatible token login, get an access token for future requests + """ + user = crud.authenticate(db, email=form_data.username, password=form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + ) + elif not crud.is_active(user): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user" + ) + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return { + "access_token": security.create_access_token( + user.id, expires_delta=access_token_expires + ), + "token_type": "bearer", + } + + +@router.post("/register", response_model=User) +def register( + *, + db: Session = Depends(deps.get_db), + user_in: UserCreate, +) -> Any: + """ + Register a new user. + """ + user = crud.get_by_email(db, email=user_in.email) + if user: + raise HTTPException( + status_code=400, + detail="A user with this email already exists.", + ) + user = crud.get_by_username(db, username=user_in.username) + if user: + raise HTTPException( + status_code=400, + detail="A user with this username already exists.", + ) + user = crud.create(db, obj_in=user_in) + return user + + +@router.get("/me", response_model=User) +def read_users_me( + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get current user. + """ + return current_user diff --git a/app/api/v1/collections.py b/app/api/v1/collections.py new file mode 100644 index 0000000..c001a85 --- /dev/null +++ b/app/api/v1/collections.py @@ -0,0 +1,129 @@ +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app import crud +from app.api import deps +from app.models.user import User +from app.schemas.collection import ( + Collection, + CollectionCreate, + CollectionUpdate, + CollectionWithNotes, +) +from app.schemas.note import Note + +router = APIRouter() + + +@router.get("/", response_model=list[Collection]) +def read_collections( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve collections for the current user. + """ + collections = crud.get_collections_by_owner( + db=db, owner_id=current_user.id, skip=skip, limit=limit + ) + return collections + + +@router.post("/", response_model=Collection) +def create_collection( + *, + db: Session = Depends(deps.get_db), + collection_in: CollectionCreate, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + """ + Create new collection. + """ + collection = crud.create_collection( + db=db, obj_in=collection_in, owner_id=current_user.id + ) + return collection + + +@router.put("/{id}", response_model=Collection) +def update_collection( + *, + db: Session = Depends(deps.get_db), + id: int, + collection_in: CollectionUpdate, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + """ + Update a collection. + """ + collection = crud.get_collection(db=db, collection_id=id) + if not collection: + raise HTTPException(status_code=404, detail="Collection not found") + if collection.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + collection = crud.update_collection(db=db, db_obj=collection, obj_in=collection_in) + return collection + + +@router.get("/{id}", response_model=CollectionWithNotes) +def read_collection( + *, + db: Session = Depends(deps.get_db), + id: int, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get collection by ID with all its notes. + """ + collection = crud.get_collection(db=db, collection_id=id) + if not collection: + raise HTTPException(status_code=404, detail="Collection not found") + if collection.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + return collection + + +@router.get("/{id}/notes", response_model=list[Note]) +def read_collection_notes( + *, + db: Session = Depends(deps.get_db), + id: int, + skip: int = 0, + limit: int = 100, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get notes from a specific collection. + """ + collection = crud.get_collection(db=db, collection_id=id) + if not collection: + raise HTTPException(status_code=404, detail="Collection not found") + if collection.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + notes = crud.get_multi_by_collection( + db=db, owner_id=current_user.id, collection_id=id, skip=skip, limit=limit + ) + return notes + + +@router.delete("/{id}", status_code=204, response_model=None) +def delete_collection( + *, + db: Session = Depends(deps.get_db), + id: int, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + """ + Delete a collection. + """ + collection = crud.get_collection(db=db, collection_id=id) + if not collection: + raise HTTPException(status_code=404, detail="Collection not found") + if collection.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + crud.remove_collection(db=db, collection_id=id, owner_id=current_user.id) + return None diff --git a/app/api/v1/notes.py b/app/api/v1/notes.py new file mode 100644 index 0000000..e2541fe --- /dev/null +++ b/app/api/v1/notes.py @@ -0,0 +1,136 @@ +from datetime import datetime +from typing import Any, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app import crud +from app.api import deps +from app.models.user import User +from app.schemas.note import Note, NoteCreate, NoteUpdate + +router = APIRouter() + + +@router.get("/", response_model=List[Note]) +def read_notes( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + sort_by: str = Query("created_at", description="Field to sort by (created_at, updated_at, title)"), + sort_order: str = Query("desc", description="Sort order (asc, desc)"), + search: Optional[str] = Query(None, description="Search term for title and content"), + archived: Optional[bool] = Query(None, description="Filter by archived status"), + collection_id: Optional[int] = Query(None, description="Filter by collection ID"), + start_date: Optional[datetime] = Query(None, description="Filter notes created after this date"), + end_date: Optional[datetime] = Query(None, description="Filter notes created before this date"), + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve notes for the current user with filtering and sorting options. + """ + # Search by term + if search: + return crud.search_notes( + db=db, owner_id=current_user.id, search_term=search, skip=skip, limit=limit + ) + + # Filter by archived status + if archived is not None: + if archived: + return crud.get_archived_notes( + db=db, owner_id=current_user.id, skip=skip, limit=limit + ) + else: + return db.query(crud.Note).filter( + crud.Note.owner_id == current_user.id, + not crud.Note.is_archived + ).offset(skip).limit(limit).all() + + # Filter by collection + if collection_id: + return crud.get_multi_by_collection( + db=db, owner_id=current_user.id, collection_id=collection_id, skip=skip, limit=limit + ) + + # Filter by date range + if start_date and end_date: + return crud.get_notes_created_between( + db=db, owner_id=current_user.id, start_date=start_date, end_date=end_date, skip=skip, limit=limit + ) + + # Default sorted retrieval + return crud.get_multi_by_owner_sorted( + db=db, owner_id=current_user.id, skip=skip, limit=limit, sort_by=sort_by, sort_order=sort_order + ) + + +@router.post("/", response_model=Note) +def create_note( + *, + db: Session = Depends(deps.get_db), + note_in: NoteCreate, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + """ + Create new note. + """ + note = crud.create_note(db=db, obj_in=note_in, owner_id=current_user.id) + return note + + +@router.put("/{id}", response_model=Note) +def update_note( + *, + db: Session = Depends(deps.get_db), + id: int, + note_in: NoteUpdate, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + """ + Update a note. + """ + note = crud.get_note(db=db, note_id=id) + if not note: + raise HTTPException(status_code=404, detail="Note not found") + if note.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + note = crud.update_note(db=db, db_obj=note, obj_in=note_in) + return note + + +@router.get("/{id}", response_model=Note) +def read_note( + *, + db: Session = Depends(deps.get_db), + id: int, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get note by ID. + """ + note = crud.get_note(db=db, note_id=id) + if not note: + raise HTTPException(status_code=404, detail="Note not found") + if note.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + return note + + +@router.delete("/{id}", status_code=204, response_model=None) +def delete_note( + *, + db: Session = Depends(deps.get_db), + id: int, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + """ + Delete a note. + """ + note = crud.get_note(db=db, note_id=id) + if not note: + raise HTTPException(status_code=404, detail="Note not found") + if note.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + crud.remove_note(db=db, note_id=id, owner_id=current_user.id) + return None diff --git a/app/api/v1/users.py b/app/api/v1/users.py new file mode 100644 index 0000000..0c94b02 --- /dev/null +++ b/app/api/v1/users.py @@ -0,0 +1,71 @@ +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app import crud +from app.api import deps +from app.models.user import User +from app.schemas.user import User as UserSchema +from app.schemas.user import UserUpdate + +router = APIRouter() + + +@router.get("/", response_model=list[UserSchema]) +def read_users( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve users. + """ + users = db.query(User).offset(skip).limit(limit).all() + return users + + +@router.get("/{user_id}", response_model=UserSchema) +def read_user( + user_id: int, + current_user: User = Depends(deps.get_current_active_user), + db: Session = Depends(deps.get_db), +) -> Any: + """ + Get a specific user by id. + """ + user = crud.get(db, user_id=user_id) + if user == current_user: + return user + if not user: + raise HTTPException( + status_code=404, + detail="The user with this id does not exist", + ) + return user + + +@router.put("/me", response_model=UserSchema) +def update_user_me( + *, + db: Session = Depends(deps.get_db), + user_in: UserUpdate, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + """ + Update own user. + """ + user = crud.update(db, db_obj=current_user, obj_in=user_in) + return user + + +@router.get("/me", response_model=UserSchema) +def read_user_me( + db: Session = Depends(deps.get_db), + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get current user. + """ + return current_user diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..f7f8414 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,27 @@ +import os +from pathlib import Path + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + PROJECT_NAME: str = "Notes API" + API_V1_STR: str = "/api/v1" + + # Security + SECRET_KEY: str = os.getenv("SECRET_KEY", "please_change_this_to_a_random_secret_key") + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days + + # Database + DB_DIR = Path("/app/storage/db") + DB_DIR.mkdir(parents=True, exist_ok=True) + SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite" + + # CORS + CORS_ORIGINS: list[str] = ["*"] + + class Config: + env_file = ".env" + case_sensitive = True + +settings = Settings() diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..453fafc --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,42 @@ +from datetime import datetime, timedelta +from typing import Any + +from jose import jwt +from passlib.context import CryptContext + +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +ALGORITHM = "HS256" + + +def create_access_token( + subject: str | Any, expires_delta: timedelta | None = None +) -> str: + """ + Create a new JWT access token. + """ + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """ + Verify a password against a hash. + """ + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """ + Hash a password. + """ + return pwd_context.hash(password) diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000..27e58c5 --- /dev/null +++ b/app/crud/__init__.py @@ -0,0 +1,20 @@ +from app.crud.crud_collection import create as create_collection +from app.crud.crud_collection import get as get_collection +from app.crud.crud_collection import get_multi_by_owner as get_collections_by_owner +from app.crud.crud_collection import remove as remove_collection +from app.crud.crud_collection import update as update_collection +from app.crud.crud_note import create as create_note +from app.crud.crud_note import get as get_note +from app.crud.crud_note import ( + get_archived_notes, + get_multi_by_collection, + get_multi_by_owner, + get_multi_by_owner_sorted, + get_notes_created_between, + search_notes, +) +from app.crud.crud_note import remove as remove_note +from app.crud.crud_note import update as update_note +from app.crud.crud_user import authenticate, get, get_by_email, get_by_username, is_active +from app.crud.crud_user import create as create_user +from app.crud.crud_user import update as update_user diff --git a/app/crud/crud_collection.py b/app/crud/crud_collection.py new file mode 100644 index 0000000..d00cdc9 --- /dev/null +++ b/app/crud/crud_collection.py @@ -0,0 +1,59 @@ +from typing import Any + +from sqlalchemy.orm import Session + +from app.models.collection import Collection +from app.schemas.collection import CollectionCreate, CollectionUpdate + + +def get(db: Session, collection_id: int) -> Collection | None: + return db.query(Collection).filter(Collection.id == collection_id).first() + + +def get_multi_by_owner( + db: Session, owner_id: int, skip: int = 0, limit: int = 100 +) -> list[Collection]: + return ( + db.query(Collection) + .filter(Collection.owner_id == owner_id) + .offset(skip) + .limit(limit) + .all() + ) + + +def create(db: Session, *, obj_in: CollectionCreate, owner_id: int) -> Collection: + db_obj = Collection( + **obj_in.model_dump(), + owner_id=owner_id, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def update( + db: Session, *, db_obj: Collection, obj_in: CollectionUpdate | dict[str, Any] +) -> Collection: + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.model_dump(exclude_unset=True) + for field in update_data: + if field in update_data: + setattr(db_obj, field, update_data[field]) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def remove(db: Session, *, collection_id: int, owner_id: int) -> Collection | None: + obj = db.query(Collection).filter( + Collection.id == collection_id, Collection.owner_id == owner_id + ).first() + if obj: + db.delete(obj) + db.commit() + return obj diff --git a/app/crud/crud_note.py b/app/crud/crud_note.py new file mode 100644 index 0000000..cc0ed98 --- /dev/null +++ b/app/crud/crud_note.py @@ -0,0 +1,141 @@ +from datetime import datetime +from typing import Any, Dict, List, Optional, Union + +from sqlalchemy import asc, desc +from sqlalchemy.orm import Session + +from app.models.note import Note +from app.schemas.note import NoteCreate, NoteUpdate + + +def get(db: Session, note_id: int) -> Optional[Note]: + return db.query(Note).filter(Note.id == note_id).first() + + +def get_multi_by_owner( + db: Session, owner_id: int, skip: int = 0, limit: int = 100 +) -> List[Note]: + return ( + db.query(Note) + .filter(Note.owner_id == owner_id) + .offset(skip) + .limit(limit) + .all() + ) + + +def get_multi_by_owner_sorted( + db: Session, owner_id: int, skip: int = 0, limit: int = 100, sort_by: str = "created_at", sort_order: str = "desc" +) -> List[Note]: + """Get notes sorted by specified field.""" + query = db.query(Note).filter(Note.owner_id == owner_id) + + # Determine sort field + if sort_by == "title": + sort_field = Note.title + elif sort_by == "updated_at": + sort_field = Note.updated_at + else: # Default to created_at + sort_field = Note.created_at + + # Apply sort order + if sort_order == "asc": + query = query.order_by(asc(sort_field)) + else: # Default to desc + query = query.order_by(desc(sort_field)) + + return query.offset(skip).limit(limit).all() + + +def get_multi_by_collection( + db: Session, owner_id: int, collection_id: int, skip: int = 0, limit: int = 100 +) -> List[Note]: + return ( + db.query(Note) + .filter(Note.owner_id == owner_id, Note.collection_id == collection_id) + .offset(skip) + .limit(limit) + .all() + ) + + +def search_notes( + db: Session, owner_id: int, search_term: str, skip: int = 0, limit: int = 100 +) -> List[Note]: + """Search notes by title or content.""" + search_pattern = f"%{search_term}%" + return ( + db.query(Note) + .filter( + Note.owner_id == owner_id, + (Note.title.ilike(search_pattern) | Note.content.ilike(search_pattern)) + ) + .offset(skip) + .limit(limit) + .all() + ) + + +def get_archived_notes( + db: Session, owner_id: int, skip: int = 0, limit: int = 100 +) -> List[Note]: + """Get archived notes.""" + return ( + db.query(Note) + .filter(Note.owner_id == owner_id, Note.is_archived) + .offset(skip) + .limit(limit) + .all() + ) + + +def get_notes_created_between( + db: Session, owner_id: int, start_date: datetime, end_date: datetime, skip: int = 0, limit: int = 100 +) -> List[Note]: + """Get notes created between specified dates.""" + return ( + db.query(Note) + .filter( + Note.owner_id == owner_id, + Note.created_at >= start_date, + Note.created_at <= end_date + ) + .offset(skip) + .limit(limit) + .all() + ) + + +def create(db: Session, *, obj_in: NoteCreate, owner_id: int) -> Note: + db_obj = Note( + **obj_in.model_dump(), + owner_id=owner_id, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def update( + db: Session, *, db_obj: Note, obj_in: Union[NoteUpdate, Dict[str, Any]] +) -> Note: + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.model_dump(exclude_unset=True) + for field in update_data: + if field in update_data: + setattr(db_obj, field, update_data[field]) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def remove(db: Session, *, note_id: int, owner_id: int) -> Optional[Note]: + obj = db.query(Note).filter(Note.id == note_id, Note.owner_id == owner_id).first() + if obj: + db.delete(obj) + db.commit() + return obj diff --git a/app/crud/crud_user.py b/app/crud/crud_user.py new file mode 100644 index 0000000..b7ac850 --- /dev/null +++ b/app/crud/crud_user.py @@ -0,0 +1,65 @@ +from typing import Any + +from sqlalchemy.orm import Session + +from app.core.security import get_password_hash, verify_password +from app.models.user import User +from app.schemas.user import UserCreate, UserUpdate + + +def get_by_email(db: Session, email: str) -> User | None: + return db.query(User).filter(User.email == email).first() + + +def get_by_username(db: Session, username: str) -> User | None: + return db.query(User).filter(User.username == username).first() + + +def get(db: Session, user_id: int) -> User | None: + return db.query(User).filter(User.id == user_id).first() + + +def create(db: Session, obj_in: UserCreate) -> User: + db_obj = User( + email=obj_in.email, + username=obj_in.username, + hashed_password=get_password_hash(obj_in.password), + is_active=True, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def update( + db: Session, db_obj: User, obj_in: UserUpdate | dict[str, Any] +) -> User: + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.model_dump(exclude_unset=True) + if update_data.get("password"): + hashed_password = get_password_hash(update_data["password"]) + del update_data["password"] + update_data["hashed_password"] = hashed_password + for field in update_data: + if field in update_data: + setattr(db_obj, field, update_data[field]) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def authenticate(db: Session, email: str, password: str) -> User | None: + user = get_by_email(db, email=email) + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + return user + + +def is_active(user: User) -> bool: + return user.is_active diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..a83e97a --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,22 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from app.core.config import settings + +engine = create_engine( + settings.SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +# Dependency +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..343f8ff --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,3 @@ +from app.models.collection import Collection +from app.models.note import Note +from app.models.user import User diff --git a/app/models/collection.py b/app/models/collection.py new file mode 100644 index 0000000..97ba9f8 --- /dev/null +++ b/app/models/collection.py @@ -0,0 +1,21 @@ +from datetime import datetime + +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import relationship + +from app.db.session import Base + + +class Collection(Base): + __tablename__ = "collections" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, index=True) + description = Column(String, nullable=True) + owner_id = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + owner = relationship("User", back_populates="collections") + notes = relationship("Note", back_populates="collection", cascade="all, delete-orphan") diff --git a/app/models/note.py b/app/models/note.py new file mode 100644 index 0000000..44d4c61 --- /dev/null +++ b/app/models/note.py @@ -0,0 +1,23 @@ +from datetime import datetime + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship + +from app.db.session import Base + + +class Note(Base): + __tablename__ = "notes" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, index=True) + content = Column(Text) + is_archived = Column(Boolean, default=False) + owner_id = Column(Integer, ForeignKey("users.id")) + collection_id = Column(Integer, ForeignKey("collections.id"), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, index=True) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + owner = relationship("User", back_populates="notes") + collection = relationship("Collection", back_populates="notes") diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..91741d4 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,22 @@ +from datetime import datetime + +from sqlalchemy import Boolean, Column, DateTime, Integer, String +from sqlalchemy.orm import relationship + +from app.db.session import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + username = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + notes = relationship("Note", back_populates="owner", cascade="all, delete-orphan") + collections = relationship("Collection", back_populates="owner", cascade="all, delete-orphan") diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..c3a39f1 --- /dev/null +++ b/app/schemas/auth.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel, EmailStr + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenPayload(BaseModel): + sub: str | None = None + + +class UserBase(BaseModel): + email: EmailStr + username: str + + +class UserCreate(UserBase): + password: str + + +class UserLogin(BaseModel): + email: EmailStr + password: str + + +class UserInDB(UserBase): + id: int + is_active: bool + + class Config: + from_attributes = True + + +class User(UserInDB): + pass diff --git a/app/schemas/collection.py b/app/schemas/collection.py new file mode 100644 index 0000000..584f447 --- /dev/null +++ b/app/schemas/collection.py @@ -0,0 +1,37 @@ +from datetime import datetime + +from pydantic import BaseModel + +from app.schemas.note import Note + + +class CollectionBase(BaseModel): + name: str + description: str | None = None + + +class CollectionCreate(CollectionBase): + pass + + +class CollectionUpdate(BaseModel): + name: str | None = None + description: str | None = None + + +class CollectionInDBBase(CollectionBase): + id: int + owner_id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class Collection(CollectionInDBBase): + pass + + +class CollectionWithNotes(Collection): + notes: list[Note] = [] diff --git a/app/schemas/note.py b/app/schemas/note.py new file mode 100644 index 0000000..e488d8c --- /dev/null +++ b/app/schemas/note.py @@ -0,0 +1,35 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class NoteBase(BaseModel): + title: str + content: str + is_archived: bool = False + collection_id: int | None = None + + +class NoteCreate(NoteBase): + pass + + +class NoteUpdate(BaseModel): + title: str | None = None + content: str | None = None + is_archived: bool | None = None + collection_id: int | None = None + + +class NoteInDBBase(NoteBase): + id: int + owner_id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class Note(NoteInDBBase): + pass diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..abea390 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,36 @@ +from datetime import datetime + +from pydantic import BaseModel, EmailStr + + +class UserBase(BaseModel): + email: EmailStr + username: str + + +class UserCreate(UserBase): + password: str + + +class UserUpdate(BaseModel): + email: EmailStr | None = None + username: str | None = None + password: str | None = None + + +class UserInDBBase(UserBase): + id: int + is_active: bool + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class User(UserInDBBase): + pass + + +class UserInDB(UserInDBBase): + hashed_password: str diff --git a/main.py b/main.py new file mode 100644 index 0000000..cd5403a --- /dev/null +++ b/main.py @@ -0,0 +1,33 @@ +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1.api import api_router +from app.core.config import settings + +app = FastAPI( + title=settings.PROJECT_NAME, + openapi_url="/openapi.json", + docs_url="/docs", + redoc_url="/redoc", +) + +# Set CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include API router +app.include_router(api_router) + +# Health check endpoint +@app.get("/health", tags=["health"]) +async def health_check(): + return {"status": "ok"} + +if __name__ == "__main__": + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..db28b87 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] +ignore = ["E501", "S104"] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] +"alembic/env.py" = ["I001"] +"alembic/versions/*.py" = ["I001"] + +[tool.ruff.lint.isort] +known-third-party = ["fastapi", "pydantic", "sqlalchemy", "alembic"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..269e8e3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +fastapi>=0.95.0 +uvicorn>=0.22.0 +sqlalchemy>=2.0.0 +alembic>=1.10.0 +pydantic>=2.0.0 +python-multipart>=0.0.6 +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 +ruff>=0.0.0 +python-dotenv>=1.0.0 +email-validator>=2.0.0 \ No newline at end of file