Implement Notes API with FastAPI and SQLite

- Set up project structure with FastAPI
- Implement user authentication system with JWT tokens
- Create database models for users, notes, and collections
- Set up SQLAlchemy ORM and Alembic migrations
- Implement CRUD operations for notes and collections
- Add filtering and sorting capabilities for notes
- Implement health check endpoint
- Update project documentation
This commit is contained in:
Automated Action 2025-05-31 14:54:14 +00:00
parent c6417a90e1
commit 741f301b11
35 changed files with 1569 additions and 2 deletions

140
README.md
View File

@ -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 <repository-url>
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

107
alembic.ini Normal file
View File

@ -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

87
alembic/env.py Normal file
View File

@ -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()

26
alembic/script.py.mako Normal file
View File

@ -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"}

View File

@ -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')

0
app/__init__.py Normal file
View File

0
app/api/__init__.py Normal file
View File

53
app/api/deps.py Normal file
View File

@ -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

0
app/api/v1/__init__.py Normal file
View File

10
app/api/v1/api.py Normal file
View File

@ -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"])

75
app/api/v1/auth.py Normal file
View File

@ -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

129
app/api/v1/collections.py Normal file
View File

@ -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

136
app/api/v1/notes.py Normal file
View File

@ -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

71
app/api/v1/users.py Normal file
View File

@ -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

0
app/core/__init__.py Normal file
View File

27
app/core/config.py Normal file
View File

@ -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()

42
app/core/security.py Normal file
View File

@ -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)

20
app/crud/__init__.py Normal file
View File

@ -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

View File

@ -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

141
app/crud/crud_note.py Normal file
View File

@ -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

65
app/crud/crud_user.py Normal file
View File

@ -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

0
app/db/__init__.py Normal file
View File

22
app/db/session.py Normal file
View File

@ -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()

3
app/models/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from app.models.collection import Collection
from app.models.note import Note
from app.models.user import User

21
app/models/collection.py Normal file
View File

@ -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")

23
app/models/note.py Normal file
View File

@ -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")

22
app/models/user.py Normal file
View File

@ -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")

0
app/schemas/__init__.py Normal file
View File

36
app/schemas/auth.py Normal file
View File

@ -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

37
app/schemas/collection.py Normal file
View File

@ -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] = []

35
app/schemas/note.py Normal file
View File

@ -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

36
app/schemas/user.py Normal file
View File

@ -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

33
main.py Normal file
View File

@ -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)

15
pyproject.toml Normal file
View File

@ -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"]

11
requirements.txt Normal file
View File

@ -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