Implement note-taking API with FastAPI and SQLite

This commit is contained in:
Automated Action 2025-06-05 14:29:01 +00:00
parent dae2e4cfe2
commit db38c7e8a0
56 changed files with 2445 additions and 2 deletions

131
README.md
View File

@ -1,3 +1,130 @@
# FastAPI Application # NoteTaker API
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. A full-featured Note Taking API built with FastAPI and SQLite that allows users to create, manage, and organize their notes.
## Features
- User authentication with JWT
- Create, read, update, and delete notes
- Tag notes for better organization
- Archive and pin notes
- Search functionality for notes
- RESTful API with Swagger documentation
## Tech Stack
- **Backend Framework**: FastAPI
- **Database**: SQLite with SQLAlchemy ORM
- **Authentication**: JWT (JSON Web Tokens)
- **Migration Tool**: Alembic
- **Documentation**: Swagger UI, ReDoc
## Getting Started
### Prerequisites
- Python 3.8+
- pip
### Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd notetaker-api
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Run database migrations:
```bash
alembic upgrade head
```
4. Run the development server:
```bash
uvicorn main:app --reload
```
The API will be available at `http://localhost:8000`
### Environment Variables
Set the following environment variables for production:
- `SECRET_KEY`: A secret key for JWT token generation
- `ALGORITHM`: The algorithm used for JWT encoding (default: "HS256")
- `ACCESS_TOKEN_EXPIRE_MINUTES`: Minutes until a token expires (default: 10080 - 7 days)
## API Documentation
The API documentation is available at:
- Swagger UI: `http://localhost:8000/docs`
- ReDoc: `http://localhost:8000/redoc`
## Database Schema
### User
- id (string): UUID primary key
- email (string): Unique email address
- hashed_password (string): Securely hashed password
- full_name (string): User's full name
- profile_image (string): Profile image filename
- is_active (boolean): Whether the user is active
- is_superuser (boolean): Admin status
- created_at (datetime): Account creation timestamp
- updated_at (datetime): Last update timestamp
### Note
- id (string): UUID primary key
- title (string): Note title
- content (text): Note content
- is_archived (boolean): Archive status
- is_pinned (boolean): Pin status
- user_id (string): Foreign key to User
- created_at (datetime): Creation timestamp
- updated_at (datetime): Last update timestamp
### Tag
- id (string): UUID primary key
- name (string): Tag name
- color (string): Hex color code
- created_at (datetime): Creation timestamp
### NoteTag
- id (string): UUID primary key
- note_id (string): Foreign key to Note
- tag_id (string): Foreign key to Tag
- created_at (datetime): Creation timestamp
## API Endpoints
### Authentication
- `POST /api/v1/auth/login`: Login with username and password
- `POST /api/v1/auth/register`: Register a new user
### Users
- `GET /api/v1/users/me`: Get current user info
- `PUT /api/v1/users/me`: Update current user info
- `POST /api/v1/users/me/profile-image`: Upload profile image
### Notes
- `GET /api/v1/notes`: List user notes
- `GET /api/v1/notes/search`: Search notes
- `POST /api/v1/notes`: Create a new note
- `GET /api/v1/notes/{note_id}`: Get a specific note
- `PUT /api/v1/notes/{note_id}`: Update a note
- `DELETE /api/v1/notes/{note_id}`: Delete a note
- `PATCH /api/v1/notes/{note_id}/archive`: Archive/unarchive a note
- `PATCH /api/v1/notes/{note_id}/pin`: Pin/unpin a note
### Tags
- `GET /api/v1/tags`: List all tags
- `POST /api/v1/tags`: Create a new tag
- `GET /api/v1/tags/{tag_id}`: Get a specific tag
- `PUT /api/v1/tags/{tag_id}`: Update a tag
- `DELETE /api/v1/tags/{tag_id}`: Delete a tag

0
\ Normal file
View File

74
alembic.ini Normal file
View File

@ -0,0 +1,74 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# timezone to use when rendering the date
# within the migration file as well as the filename.
# 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
# version_locations = %(here)s/bar %(here)s/bat alembic/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# SQLite URL example - use absolute path
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
# 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 alembic import context
from sqlalchemy import engine_from_config, pool
# 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.
fileConfig(config.config_file_name)
# Add the project directory to the python path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Import the SQLAlchemy metadata and database URL
from app.db.base import Base # noqa
from app.core.config import settings # noqa
# Set the SQLAlchemy URL in the Alembic config
config.set_main_option("sqlalchemy.url", settings.SQLALCHEMY_DATABASE_URL)
# 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.
target_metadata = Base.metadata
def run_migrations_offline():
"""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():
"""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:
# Check if we're using SQLite and enable batch mode if so
is_sqlite = connection.dialect.name == "sqlite"
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=is_sqlite, # Enable batch mode for SQLite
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

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

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,86 @@
"""Initial tables for NoteTaker app
Revision ID: 01_initial_tables
Revises:
Create Date: 2023-11-10
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '01_initial_tables'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Create user table
op.create_table(
'user',
sa.Column('id', sa.String(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('full_name', sa.String(), nullable=True),
sa.Column('profile_image', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('is_superuser', sa.Boolean(), nullable=True, default=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_index('ix_user_email', 'user', ['email'], unique=True)
op.create_index('ix_user_full_name', 'user', ['full_name'], unique=False)
op.create_index('ix_user_id', 'user', ['id'], unique=False)
# Create note table
op.create_table(
'note',
sa.Column('id', sa.String(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('is_archived', sa.Boolean(), default=False, nullable=True),
sa.Column('is_pinned', sa.Boolean(), default=False, nullable=True),
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_note_id', 'note', ['id'], unique=False)
op.create_index('ix_note_title', 'note', ['title'], unique=False)
# Create tag table
op.create_table(
'tag',
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('color', sa.String(), nullable=True, default='#FFFFFF'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_tag_id', 'tag', ['id'], unique=False)
op.create_index('ix_tag_name', 'tag', ['name'], unique=False)
# Create note_tag association table
op.create_table(
'notetag',
sa.Column('id', sa.String(), nullable=False),
sa.Column('note_id', sa.String(), nullable=False),
sa.Column('tag_id', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
sa.ForeignKeyConstraint(['note_id'], ['note.id'], ),
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('note_id', 'tag_id', name='unique_note_tag')
)
op.create_index('ix_notetag_id', 'notetag', ['id'], unique=False)
def downgrade():
op.drop_table('notetag')
op.drop_table('tag')
op.drop_table('note')
op.drop_table('user')

1
app/__init__.py Normal file
View File

@ -0,0 +1 @@
# app package

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

@ -0,0 +1 @@
# api package

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

@ -0,0 +1,54 @@
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 models, schemas
from app.core import security
from app.core.config import settings
from app.db.session import get_db
reusable_oauth2 = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login"
)
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(reusable_oauth2)
) -> models.User:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
)
token_data = schemas.TokenPayload(**payload)
except (JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)
user = db.query(models.User).filter(models.User.id == token_data.sub).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return user
def get_current_active_user(
current_user: models.User = Depends(get_current_user),
) -> models.User:
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
def get_current_active_superuser(
current_user: models.User = Depends(get_current_user),
) -> models.User:
if not current_user.is_superuser:
raise HTTPException(
status_code=400, detail="The user doesn't have enough privileges"
)
return current_user

View File

@ -0,0 +1,9 @@
from fastapi import APIRouter
from app.api.routes import auth, notes, tags, users
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
api_router.include_router(users.router, prefix="/users", tags=["Users"])
api_router.include_router(notes.router, prefix="/notes", tags=["Notes"])
api_router.include_router(tags.router, prefix="/tags", tags=["Tags"])

156
app/api/routes/albums.py Normal file
View File

@ -0,0 +1,156 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
from app.core.config import settings
from app.utils.file_storage import save_upload_file, validate_image_file
router = APIRouter()
@router.get("/", response_model=List[schemas.Album])
def read_albums(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
title: Optional[str] = Query(None, description="Filter albums by title"),
artist_id: Optional[str] = Query(None, description="Filter albums by artist ID"),
) -> Any:
"""
Retrieve albums.
"""
if artist_id:
albums = crud.album.get_by_artist(db, artist_id=artist_id, skip=skip, limit=limit)
elif title:
albums = crud.album.search_by_title(db, title=title, skip=skip, limit=limit)
else:
albums = crud.album.get_multi(db, skip=skip, limit=limit)
return albums
@router.post("/", response_model=schemas.Album)
def create_album(
*,
db: Session = Depends(deps.get_db),
album_in: schemas.AlbumCreate,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Create new album.
"""
# Verify artist exists
artist = crud.artist.get(db, id=album_in.artist_id)
if not artist:
raise HTTPException(
status_code=404,
detail="Artist not found",
)
album = crud.album.create(db, obj_in=album_in)
return album
@router.post("/{album_id}/cover", response_model=schemas.Album)
async def upload_album_cover(
*,
db: Session = Depends(deps.get_db),
album_id: str,
file: UploadFile = File(...),
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Upload a cover image for an album.
"""
album = crud.album.get(db, id=album_id)
if not album:
raise HTTPException(
status_code=404,
detail="Album not found",
)
validate_image_file(file)
# Save the album cover
filename = save_upload_file(
file,
settings.ALBUM_COVERS_DIR,
file_id=album_id,
)
# Update the album's cover_image field
album_in = schemas.AlbumUpdate(cover_image=filename)
album = crud.album.update(db, db_obj=album, obj_in=album_in)
return album
@router.get("/{album_id}", response_model=schemas.Album)
def read_album(
*,
db: Session = Depends(deps.get_db),
album_id: str,
) -> Any:
"""
Get album by ID.
"""
album = crud.album.get(db, id=album_id)
if not album:
raise HTTPException(
status_code=404,
detail="Album not found",
)
return album
@router.put("/{album_id}", response_model=schemas.Album)
def update_album(
*,
db: Session = Depends(deps.get_db),
album_id: str,
album_in: schemas.AlbumUpdate,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Update an album.
"""
album = crud.album.get(db, id=album_id)
if not album:
raise HTTPException(
status_code=404,
detail="Album not found",
)
# Verify artist exists if artist_id is provided
if album_in.artist_id:
artist = crud.artist.get(db, id=album_in.artist_id)
if not artist:
raise HTTPException(
status_code=404,
detail="Artist not found",
)
album = crud.album.update(db, db_obj=album, obj_in=album_in)
return album
@router.delete("/{album_id}", response_model=schemas.Album)
def delete_album(
*,
db: Session = Depends(deps.get_db),
album_id: str,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Delete an album.
"""
album = crud.album.get(db, id=album_id)
if not album:
raise HTTPException(
status_code=404,
detail="Album not found",
)
album = crud.album.remove(db, id=album_id)
return album

135
app/api/routes/artists.py Normal file
View File

@ -0,0 +1,135 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
from app.core.config import settings
from app.utils.file_storage import save_upload_file, validate_image_file
router = APIRouter()
@router.get("/", response_model=List[schemas.Artist])
def read_artists(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
name: Optional[str] = Query(None, description="Filter artists by name"),
) -> Any:
"""
Retrieve artists.
"""
if name:
artists = crud.artist.search_by_name(db, name=name, skip=skip, limit=limit)
else:
artists = crud.artist.get_multi(db, skip=skip, limit=limit)
return artists
@router.post("/", response_model=schemas.Artist)
def create_artist(
*,
db: Session = Depends(deps.get_db),
artist_in: schemas.ArtistCreate,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Create new artist.
"""
artist = crud.artist.create(db, obj_in=artist_in)
return artist
@router.post("/{artist_id}/image", response_model=schemas.Artist)
async def upload_artist_image(
*,
db: Session = Depends(deps.get_db),
artist_id: str,
file: UploadFile = File(...),
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Upload an image for an artist.
"""
artist = crud.artist.get(db, id=artist_id)
if not artist:
raise HTTPException(
status_code=404,
detail="Artist not found",
)
validate_image_file(file)
# Save the artist image
filename = save_upload_file(
file,
settings.ARTIST_IMAGES_DIR,
file_id=artist_id,
)
# Update the artist's image field
artist_in = schemas.ArtistUpdate(image=filename)
artist = crud.artist.update(db, db_obj=artist, obj_in=artist_in)
return artist
@router.get("/{artist_id}", response_model=schemas.Artist)
def read_artist(
*,
db: Session = Depends(deps.get_db),
artist_id: str,
) -> Any:
"""
Get artist by ID.
"""
artist = crud.artist.get(db, id=artist_id)
if not artist:
raise HTTPException(
status_code=404,
detail="Artist not found",
)
return artist
@router.put("/{artist_id}", response_model=schemas.Artist)
def update_artist(
*,
db: Session = Depends(deps.get_db),
artist_id: str,
artist_in: schemas.ArtistUpdate,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Update an artist.
"""
artist = crud.artist.get(db, id=artist_id)
if not artist:
raise HTTPException(
status_code=404,
detail="Artist not found",
)
artist = crud.artist.update(db, db_obj=artist, obj_in=artist_in)
return artist
@router.delete("/{artist_id}", response_model=schemas.Artist)
def delete_artist(
*,
db: Session = Depends(deps.get_db),
artist_id: str,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Delete an artist.
"""
artist = crud.artist.get(db, id=artist_id)
if not artist:
raise HTTPException(
status_code=404,
detail="Artist not found",
)
artist = crud.artist.remove(db, id=artist_id)
return artist

55
app/api/routes/auth.py Normal file
View File

@ -0,0 +1,55 @@
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app import crud, schemas
from app.api import deps
from app.core import security
from app.core.config import settings
router = APIRouter()
@router.post("/login", response_model=schemas.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.user.authenticate(
db, email=form_data.username, password=form_data.password
)
if not user:
raise HTTPException(status_code=400, detail="Incorrect email or password")
elif not crud.user.is_active(user):
raise HTTPException(status_code=400, 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=schemas.User)
def register_user(
*,
db: Session = Depends(deps.get_db),
user_in: schemas.UserCreate,
) -> Any:
"""
Register a new user.
"""
user = crud.user.get_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=400,
detail="A user with this email already exists in the system.",
)
user = crud.user.create(db, obj_in=user_in)
return user

159
app/api/routes/notes.py Normal file
View File

@ -0,0 +1,159 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
router = APIRouter()
@router.get("", response_model=List[schemas.Note])
def read_notes(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
archived: bool = False,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve notes for the current user.
"""
notes = crud.note.get_multi_by_user(
db=db, user_id=current_user.id, skip=skip, limit=limit, archived=archived
)
return notes
@router.get("/search", response_model=List[schemas.Note])
def search_notes(
*,
db: Session = Depends(deps.get_db),
query: str,
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Search for notes by title or content.
"""
notes = crud.note.search_notes(
db=db, user_id=current_user.id, query=query, skip=skip, limit=limit
)
return notes
@router.post("", response_model=schemas.Note)
def create_note(
*,
db: Session = Depends(deps.get_db),
note_in: schemas.NoteCreate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Create new note.
"""
note = crud.note.create_with_user(db=db, obj_in=note_in, user_id=current_user.id)
return note
@router.get("/{note_id}", response_model=schemas.NoteWithTags)
def read_note(
*,
db: Session = Depends(deps.get_db),
note_id: str,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get note by ID.
"""
note = crud.note.get(db=db, id=note_id)
if not note:
raise HTTPException(status_code=404, detail="Note not found")
if note.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")
return note
@router.put("/{note_id}", response_model=schemas.Note)
def update_note(
*,
db: Session = Depends(deps.get_db),
note_id: str,
note_in: schemas.NoteUpdate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Update a note.
"""
note = crud.note.get(db=db, id=note_id)
if not note:
raise HTTPException(status_code=404, detail="Note not found")
if note.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")
note = crud.note.update(db=db, db_obj=note, obj_in=note_in)
return note
@router.delete("/{note_id}", status_code=204, response_model=None)
def delete_note(
*,
db: Session = Depends(deps.get_db),
note_id: str,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Delete a note.
"""
note = crud.note.get(db=db, id=note_id)
if not note:
raise HTTPException(status_code=404, detail="Note not found")
if note.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")
crud.note.remove(db=db, id=note_id)
return None
@router.patch("/{note_id}/archive", response_model=schemas.Note)
def archive_note(
*,
db: Session = Depends(deps.get_db),
note_id: str,
archive: bool = True,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Archive or unarchive a note.
"""
note = crud.note.get(db=db, id=note_id)
if not note:
raise HTTPException(status_code=404, detail="Note not found")
if note.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")
note_in = schemas.NoteUpdate(is_archived=archive)
note = crud.note.update(db=db, db_obj=note, obj_in=note_in)
return note
@router.patch("/{note_id}/pin", response_model=schemas.Note)
def pin_note(
*,
db: Session = Depends(deps.get_db),
note_id: str,
pin: bool = True,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Pin or unpin a note.
"""
note = crud.note.get(db=db, id=note_id)
if not note:
raise HTTPException(status_code=404, detail="Note not found")
if note.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")
note_in = schemas.NoteUpdate(is_pinned=pin)
note = crud.note.update(db=db, db_obj=note, obj_in=note_in)
return note

105
app/api/routes/tags.py Normal file
View File

@ -0,0 +1,105 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
router = APIRouter()
@router.get("", response_model=List[schemas.Tag])
def read_tags(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve tags.
"""
tags = crud.tag.get_multi(db=db, skip=skip, limit=limit)
return tags
@router.post("", response_model=schemas.Tag)
def create_tag(
*,
db: Session = Depends(deps.get_db),
tag_in: schemas.TagCreate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Create new tag.
"""
tag = crud.tag.get_by_name(db=db, name=tag_in.name)
if tag:
raise HTTPException(
status_code=400,
detail="The tag with this name already exists in the system.",
)
tag = crud.tag.create(db=db, obj_in=tag_in)
return tag
@router.get("/{tag_id}", response_model=schemas.Tag)
def read_tag(
*,
db: Session = Depends(deps.get_db),
tag_id: str,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get tag by ID.
"""
tag = crud.tag.get(db=db, id=tag_id)
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
return tag
@router.put("/{tag_id}", response_model=schemas.Tag)
def update_tag(
*,
db: Session = Depends(deps.get_db),
tag_id: str,
tag_in: schemas.TagUpdate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Update a tag.
"""
tag = crud.tag.get(db=db, id=tag_id)
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
# Check if updating to a name that already exists
if tag_in.name and tag_in.name != tag.name:
existing_tag = crud.tag.get_by_name(db=db, name=tag_in.name)
if existing_tag:
raise HTTPException(
status_code=400,
detail="The tag with this name already exists in the system.",
)
tag = crud.tag.update(db=db, db_obj=tag, obj_in=tag_in)
return tag
@router.delete("/{tag_id}", status_code=204, response_model=None)
def delete_tag(
*,
db: Session = Depends(deps.get_db),
tag_id: str,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Delete a tag.
"""
tag = crud.tag.get(db=db, id=tag_id)
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
crud.tag.remove(db=db, id=tag_id)
return None

93
app/api/routes/users.py Normal file
View File

@ -0,0 +1,93 @@
from typing import Any
from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile
from fastapi.encoders import jsonable_encoder
from pydantic import EmailStr
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
from app.core.config import settings
from app.utils.file_storage import save_upload_file, validate_image_file
router = APIRouter()
@router.get("/me", response_model=schemas.User)
def read_user_me(
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get current user.
"""
return current_user
@router.put("/me", response_model=schemas.User)
def update_user_me(
*,
db: Session = Depends(deps.get_db),
password: str = Body(None),
full_name: str = Body(None),
email: EmailStr = Body(None),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Update own user.
"""
current_user_data = jsonable_encoder(current_user)
user_in = schemas.UserUpdate(**current_user_data)
if password is not None:
user_in.password = password
if full_name is not None:
user_in.full_name = full_name
if email is not None:
user_in.email = email
user = crud.user.update(db, db_obj=current_user, obj_in=user_in)
return user
@router.post("/me/profile-image", response_model=schemas.User)
async def upload_profile_image(
*,
db: Session = Depends(deps.get_db),
file: UploadFile = File(...),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Upload a profile image for the current user.
"""
validate_image_file(file)
# Save the profile image
filename = save_upload_file(
file,
str(settings.ATTACHMENTS_DIR),
file_id=current_user.id,
)
# Update the user's profile image field
user_in = schemas.UserUpdate(profile_image=filename)
user = crud.user.update(db, db_obj=current_user, obj_in=user_in)
return user
@router.get("/{user_id}", response_model=schemas.User)
def read_user_by_id(
user_id: str,
current_user: models.User = Depends(deps.get_current_active_user),
db: Session = Depends(deps.get_db),
) -> Any:
"""
Get a specific user by id.
"""
user = crud.user.get(db, id=user_id)
if user == current_user:
return user
if not crud.user.is_superuser(current_user):
raise HTTPException(
status_code=400, detail="The user doesn't have enough privileges"
)
return user

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

@ -0,0 +1 @@
# Core module initialization

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

@ -0,0 +1,44 @@
import os
from pathlib import Path
from typing import Any, List
from pydantic import BaseSettings, validator
class Settings(BaseSettings):
PROJECT_NAME: str = "NoteTaker"
API_V1_STR: str = "/api/v1"
# CORS Configuration
BACKEND_CORS_ORIGINS: List[str] = ["*"]
@validator("BACKEND_CORS_ORIGINS", pre=True)
def assemble_cors_origins(cls, v: Any) -> List[str]:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
return v
# JWT Configuration
SECRET_KEY: str = os.getenv("SECRET_KEY", "supersecretkey")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
# Database Configuration
DB_DIR: Path = Path("/app") / "storage" / "db"
SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite"
# File Storage Configuration
STORAGE_DIR: Path = Path("/app") / "storage"
ATTACHMENTS_DIR: Path = STORAGE_DIR / "attachments"
TEMP_DIR: Path = STORAGE_DIR / "temp"
# Create directories if they don't exist
DB_DIR.mkdir(parents=True, exist_ok=True)
ATTACHMENTS_DIR.mkdir(parents=True, exist_ok=True)
TEMP_DIR.mkdir(parents=True, exist_ok=True)
class Config:
case_sensitive = True
settings = Settings()

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

@ -0,0 +1,33 @@
from datetime import datetime, timedelta
from typing import Any, Union
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: Union[str, Any], expires_delta: timedelta = None
) -> str:
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:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)

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

@ -0,0 +1,6 @@
# Explicitly re-export these modules to make them available when importing from app.crud
from app.crud.crud_note import note
from app.crud.crud_tag import tag
from app.crud.crud_user import user
__all__ = ["user", "note", "tag"]

66
app/crud/base.py Normal file
View File

@ -0,0 +1,66 @@
from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.db.base_class import Base
ModelType = TypeVar("ModelType", bound=Base)
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
def __init__(self, model: Type[ModelType]):
"""
CRUD object with default methods to Create, Read, Update, Delete (CRUD).
**Parameters**
* `model`: A SQLAlchemy model class
* `schema`: A Pydantic model (schema) class
"""
self.model = model
def get(self, db: Session, id: Any) -> Optional[ModelType]:
return db.query(self.model).filter(self.model.id == id).first()
def get_multi(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[ModelType]:
return db.query(self.model).offset(skip).limit(limit).all()
def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType:
obj_in_data = jsonable_encoder(obj_in)
db_obj = self.model(**obj_in_data)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self,
db: Session,
*,
db_obj: ModelType,
obj_in: Union[UpdateSchemaType, Dict[str, Any]]
) -> ModelType:
obj_data = jsonable_encoder(db_obj)
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.dict(exclude_unset=True)
for field in obj_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(self, db: Session, *, id: Any) -> ModelType:
obj = db.query(self.model).get(id)
db.delete(obj)
db.commit()
return obj

34
app/crud/crud_album.py Normal file
View File

@ -0,0 +1,34 @@
import uuid
from typing import List
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.album import Album
from app.schemas.album import AlbumCreate, AlbumUpdate
class CRUDAlbum(CRUDBase[Album, AlbumCreate, AlbumUpdate]):
def create(self, db: Session, *, obj_in: AlbumCreate) -> Album:
album_id = str(uuid.uuid4())
db_obj = Album(
id=album_id,
title=obj_in.title,
artist_id=obj_in.artist_id,
release_year=obj_in.release_year,
cover_image=obj_in.cover_image,
description=obj_in.description,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def get_by_artist(self, db: Session, *, artist_id: str, skip: int = 0, limit: int = 100) -> List[Album]:
return db.query(Album).filter(Album.artist_id == artist_id).offset(skip).limit(limit).all()
def search_by_title(self, db: Session, *, title: str, skip: int = 0, limit: int = 100) -> List[Album]:
return db.query(Album).filter(Album.title.ilike(f"%{title}%")).offset(skip).limit(limit).all()
album = CRUDAlbum(Album)

32
app/crud/crud_artist.py Normal file
View File

@ -0,0 +1,32 @@
import uuid
from typing import List, Optional
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.artist import Artist
from app.schemas.artist import ArtistCreate, ArtistUpdate
class CRUDArtist(CRUDBase[Artist, ArtistCreate, ArtistUpdate]):
def create(self, db: Session, *, obj_in: ArtistCreate) -> Artist:
artist_id = str(uuid.uuid4())
db_obj = Artist(
id=artist_id,
name=obj_in.name,
bio=obj_in.bio,
image=obj_in.image,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def get_by_name(self, db: Session, *, name: str) -> Optional[Artist]:
return db.query(Artist).filter(Artist.name == name).first()
def search_by_name(self, db: Session, *, name: str, skip: int = 0, limit: int = 100) -> List[Artist]:
return db.query(Artist).filter(Artist.name.ilike(f"%{name}%")).offset(skip).limit(limit).all()
artist = CRUDArtist(Artist)

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

@ -0,0 +1,130 @@
import uuid
from typing import Any, Dict, List, Union
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.note import Note
from app.models.note_tag import NoteTag
from app.schemas.note import NoteCreate, NoteUpdate
class CRUDNote(CRUDBase[Note, NoteCreate, NoteUpdate]):
def get_multi_by_user(
self, db: Session, *, user_id: str, skip: int = 0, limit: int = 100, archived: bool = False
) -> List[Note]:
"""
Get multiple notes for a specific user.
"""
return (
db.query(self.model)
.filter(Note.user_id == user_id, Note.is_archived == archived)
.order_by(Note.is_pinned.desc(), Note.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
def create_with_user(
self, db: Session, *, obj_in: NoteCreate, user_id: str
) -> Note:
"""
Create a new note for a specific user.
"""
note_id = str(uuid.uuid4())
db_obj = Note(
id=note_id,
title=obj_in.title,
content=obj_in.content,
is_archived=obj_in.is_archived,
is_pinned=obj_in.is_pinned,
user_id=user_id,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Add tags if provided
if obj_in.tag_ids:
for tag_id in obj_in.tag_ids:
note_tag = NoteTag(
id=str(uuid.uuid4()),
note_id=note_id,
tag_id=tag_id,
)
db.add(note_tag)
db.commit()
db.refresh(db_obj)
return db_obj
def update_note_tags(
self, db: Session, *, db_obj: Note, tag_ids: List[str]
) -> Note:
"""
Update the tags associated with a note.
"""
# Remove all existing tags
db.query(NoteTag).filter(NoteTag.note_id == db_obj.id).delete()
# Add new tags
for tag_id in tag_ids:
note_tag = NoteTag(
id=str(uuid.uuid4()),
note_id=db_obj.id,
tag_id=tag_id,
)
db.add(note_tag)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self, db: Session, *, db_obj: Note, obj_in: Union[NoteUpdate, Dict[str, Any]]
) -> Note:
"""
Update a note.
"""
if isinstance(obj_in, dict):
update_data = obj_in
tag_ids = update_data.pop("tag_ids", None)
else:
update_data = obj_in.dict(exclude_unset=True)
tag_ids = update_data.pop("tag_ids", None) if hasattr(obj_in, "tag_ids") else None
# Update the note
obj = super().update(db, db_obj=db_obj, obj_in=update_data)
# Update tags if provided
if tag_ids is not None:
self.update_note_tags(db, db_obj=obj, tag_ids=tag_ids)
db.refresh(obj)
return obj
def search_notes(
self, db: Session, *, user_id: str, query: str, skip: int = 0, limit: int = 100
) -> List[Note]:
"""
Search for notes by title or content.
"""
search_term = f"%{query}%"
return (
db.query(self.model)
.filter(
Note.user_id == user_id,
or_(
Note.title.ilike(search_term),
Note.content.ilike(search_term)
)
)
.order_by(Note.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
note = CRUDNote(Note)

42
app/crud/crud_song.py Normal file
View File

@ -0,0 +1,42 @@
import uuid
from typing import List
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.song import Song
from app.schemas.song import SongCreate, SongUpdate
class CRUDSong(CRUDBase[Song, SongCreate, SongUpdate]):
def create(self, db: Session, *, obj_in: SongCreate) -> Song:
song_id = str(uuid.uuid4())
db_obj = Song(
id=song_id,
title=obj_in.title,
artist_id=obj_in.artist_id,
album_id=obj_in.album_id,
file_path=obj_in.file_path,
duration=obj_in.duration,
genre=obj_in.genre,
track_number=obj_in.track_number,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def get_by_artist(self, db: Session, *, artist_id: str, skip: int = 0, limit: int = 100) -> List[Song]:
return db.query(Song).filter(Song.artist_id == artist_id).offset(skip).limit(limit).all()
def get_by_album(self, db: Session, *, album_id: str, skip: int = 0, limit: int = 100) -> List[Song]:
return db.query(Song).filter(Song.album_id == album_id).offset(skip).limit(limit).all()
def search_by_title(self, db: Session, *, title: str, skip: int = 0, limit: int = 100) -> List[Song]:
return db.query(Song).filter(Song.title.ilike(f"%{title}%")).offset(skip).limit(limit).all()
def search_by_genre(self, db: Session, *, genre: str, skip: int = 0, limit: int = 100) -> List[Song]:
return db.query(Song).filter(Song.genre.ilike(f"%{genre}%")).offset(skip).limit(limit).all()
song = CRUDSong(Song)

60
app/crud/crud_tag.py Normal file
View File

@ -0,0 +1,60 @@
import uuid
from typing import List, Optional
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.tag import Tag
from app.schemas.tag import TagCreate, TagUpdate
class CRUDTag(CRUDBase[Tag, TagCreate, TagUpdate]):
def get_by_name(self, db: Session, *, name: str) -> Optional[Tag]:
"""
Get a tag by name.
"""
return db.query(self.model).filter(Tag.name == name).first()
def create(self, db: Session, *, obj_in: TagCreate) -> Tag:
"""
Create a new tag.
"""
tag_id = str(uuid.uuid4())
db_obj = Tag(
id=tag_id,
name=obj_in.name,
color=obj_in.color,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def get_multi_by_note(
self, db: Session, *, note_id: str
) -> List[Tag]:
"""
Get all tags for a specific note.
"""
return (
db.query(self.model)
.join(Tag.notes)
.filter(Tag.notes.any(id=note_id))
.all()
)
def get_or_create(
self, db: Session, *, name: str, color: str = "#FFFFFF"
) -> Tag:
"""
Get a tag by name or create it if it doesn't exist.
"""
tag = self.get_by_name(db, name=name)
if tag:
return tag
tag_create = TagCreate(name=name, color=color)
return self.create(db, obj_in=tag_create)
tag = CRUDTag(Tag)

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

@ -0,0 +1,60 @@
import uuid
from typing import Any, Dict, Optional, Union
from sqlalchemy.orm import Session
from app.core.security import get_password_hash, verify_password
from app.crud.base import CRUDBase
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
def get_by_email(self, db: Session, *, email: str) -> Optional[User]:
return db.query(User).filter(User.email == email).first()
def create(self, db: Session, *, obj_in: UserCreate) -> User:
user_id = str(uuid.uuid4())
db_obj = User(
id=user_id,
email=obj_in.email,
hashed_password=get_password_hash(obj_in.password),
full_name=obj_in.full_name,
profile_image=obj_in.profile_image,
is_superuser=obj_in.is_superuser,
is_active=obj_in.is_active,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]]
) -> User:
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.dict(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
return super().update(db, db_obj=db_obj, obj_in=update_data)
def authenticate(self, db: Session, *, email: str, password: str) -> Optional[User]:
user = self.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(self, user: User) -> bool:
return user.is_active
def is_superuser(self, user: User) -> bool:
return user.is_superuser
user = CRUDUser(User)

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

@ -0,0 +1 @@
# db package

7
app/db/base.py Normal file
View File

@ -0,0 +1,7 @@
# Import all the models, so that Base has them before being
# imported by Alembic
from app.db.base_class import Base # noqa
from app.models.user import User # noqa
from app.models.note import Note # noqa
from app.models.tag import Tag # noqa
from app.models.note_tag import NoteTag # noqa

13
app/db/base_class.py Normal file
View File

@ -0,0 +1,13 @@
from typing import Any
from sqlalchemy.ext.declarative import as_declarative, declared_attr
@as_declarative()
class Base:
id: Any
__name__: str
# Generate __tablename__ automatically
@declared_attr
def __tablename__(cls) -> str:
return cls.__name__.lower()

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

@ -0,0 +1,24 @@
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
DB_DIR = Path("/app") / "storage" / "db"
DB_DIR.mkdir(parents=True, exist_ok=True)
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Dependency
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

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

@ -0,0 +1 @@
# models package

20
app/models/album.py Normal file
View File

@ -0,0 +1,20 @@
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base_class import Base
class Album(Base):
id = Column(String, primary_key=True, index=True)
title = Column(String, index=True, nullable=False)
artist_id = Column(String, ForeignKey("artist.id"), nullable=False)
release_year = Column(Integer, nullable=True)
cover_image = Column(String, nullable=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
artist = relationship("Artist", back_populates="albums")
songs = relationship("Song", back_populates="album", cascade="all, delete-orphan")

18
app/models/artist.py Normal file
View File

@ -0,0 +1,18 @@
from sqlalchemy import Column, DateTime, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base_class import Base
class Artist(Base):
id = Column(String, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
bio = Column(Text, nullable=True)
image = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
albums = relationship("Album", back_populates="artist", cascade="all, delete-orphan")
songs = relationship("Song", back_populates="artist", cascade="all, delete-orphan")

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

@ -0,0 +1,20 @@
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base_class import Base
class Note(Base):
id = Column(String, primary_key=True, index=True)
title = Column(String, index=True, nullable=False)
content = Column(Text, nullable=True)
is_archived = Column(Boolean, default=False)
is_pinned = Column(Boolean, default=False)
user_id = Column(String, ForeignKey("user.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
user = relationship("User", back_populates="notes")
tags = relationship("Tag", secondary="notetag", back_populates="notes")

14
app/models/note_tag.py Normal file
View File

@ -0,0 +1,14 @@
from sqlalchemy import Column, DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy.sql import func
from app.db.base_class import Base
class NoteTag(Base):
id = Column(String, primary_key=True, index=True)
note_id = Column(String, ForeignKey("note.id"), nullable=False)
tag_id = Column(String, ForeignKey("tag.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Ensure a tag can only be associated with a note once
__table_args__ = (UniqueConstraint('note_id', 'tag_id', name='unique_note_tag'),)

19
app/models/playlist.py Normal file
View File

@ -0,0 +1,19 @@
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base_class import Base
class Playlist(Base):
id = Column(String, primary_key=True, index=True)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
user_id = Column(String, ForeignKey("user.id"), nullable=False)
is_public = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
user = relationship("User", back_populates="playlists")
songs = relationship("PlaylistSong", back_populates="playlist", cascade="all, delete-orphan")

View File

@ -0,0 +1,17 @@
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base_class import Base
class PlaylistSong(Base):
id = Column(String, primary_key=True, index=True)
playlist_id = Column(String, ForeignKey("playlist.id"), nullable=False)
song_id = Column(String, ForeignKey("song.id"), nullable=False)
position = Column(Integer, nullable=False)
added_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
playlist = relationship("Playlist", back_populates="songs")
song = relationship("Song", back_populates="playlists")

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

@ -0,0 +1,23 @@
from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base_class import Base
class Song(Base):
id = Column(String, primary_key=True, index=True)
title = Column(String, index=True, nullable=False)
artist_id = Column(String, ForeignKey("artist.id"), nullable=False)
album_id = Column(String, ForeignKey("album.id"), nullable=True)
file_path = Column(String, nullable=False)
duration = Column(Float, nullable=True) # Duration in seconds
genre = Column(String, nullable=True)
track_number = Column(Integer, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
artist = relationship("Artist", back_populates="songs")
album = relationship("Album", back_populates="songs")
playlists = relationship("PlaylistSong", back_populates="song", cascade="all, delete-orphan")

15
app/models/tag.py Normal file
View File

@ -0,0 +1,15 @@
from sqlalchemy import Column, DateTime, String
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base_class import Base
class Tag(Base):
id = Column(String, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
color = Column(String, nullable=True, default="#FFFFFF")
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
notes = relationship("Note", secondary="notetag", back_populates="tags")

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

@ -0,0 +1,20 @@
from sqlalchemy import Boolean, Column, DateTime, String
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base_class import Base
class User(Base):
id = Column(String, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
full_name = Column(String, index=True)
profile_image = Column(String, nullable=True)
is_active = Column(Boolean(), default=True)
is_superuser = Column(Boolean(), default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
notes = relationship("Note", back_populates="user", cascade="all, delete-orphan")

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

@ -0,0 +1 @@
# Schema package

43
app/schemas/album.py Normal file
View File

@ -0,0 +1,43 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
# Shared properties
class AlbumBase(BaseModel):
title: str
artist_id: str
release_year: Optional[int] = None
cover_image: Optional[str] = None
description: Optional[str] = None
# Properties to receive via API on creation
class AlbumCreate(AlbumBase):
pass
# Properties to receive via API on update
class AlbumUpdate(AlbumBase):
title: Optional[str] = None
artist_id: Optional[str] = None
class AlbumInDBBase(AlbumBase):
id: str
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# Additional properties to return via API
class Album(AlbumInDBBase):
pass
# Additional properties stored in DB
class AlbumInDB(AlbumInDBBase):
pass

40
app/schemas/artist.py Normal file
View File

@ -0,0 +1,40 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
# Shared properties
class ArtistBase(BaseModel):
name: str
bio: Optional[str] = None
image: Optional[str] = None
# Properties to receive via API on creation
class ArtistCreate(ArtistBase):
pass
# Properties to receive via API on update
class ArtistUpdate(ArtistBase):
name: Optional[str] = None
class ArtistInDBBase(ArtistBase):
id: str
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# Additional properties to return via API
class Artist(ArtistInDBBase):
pass
# Additional properties stored in DB
class ArtistInDB(ArtistInDBBase):
pass

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

@ -0,0 +1,48 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel
from app.schemas.tag import Tag
# Shared properties
class NoteBase(BaseModel):
title: Optional[str] = None
content: Optional[str] = None
is_archived: Optional[bool] = False
is_pinned: Optional[bool] = False
# Properties to receive via API on creation
class NoteCreate(NoteBase):
title: str
tag_ids: Optional[List[str]] = []
# Properties to receive via API on update
class NoteUpdate(NoteBase):
tag_ids: Optional[List[str]] = None
class NoteInDBBase(NoteBase):
id: str
user_id: str
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# Additional properties to return via API
class Note(NoteInDBBase):
pass
# Nested Note model with Tags
class NoteWithTags(Note):
tags: List[Tag] = []
NoteWithTags.update_forward_refs()

48
app/schemas/playlist.py Normal file
View File

@ -0,0 +1,48 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel
from app.schemas.song import Song
# Shared properties
class PlaylistBase(BaseModel):
name: str
description: Optional[str] = None
is_public: bool = True
# Properties to receive via API on creation
class PlaylistCreate(PlaylistBase):
pass
# Properties to receive via API on update
class PlaylistUpdate(PlaylistBase):
name: Optional[str] = None
class PlaylistInDBBase(PlaylistBase):
id: str
user_id: str
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# Additional properties to return via API
class Playlist(PlaylistInDBBase):
pass
# Additional properties stored in DB
class PlaylistInDB(PlaylistInDBBase):
pass
# For returning playlist with songs
class PlaylistWithSongs(Playlist):
songs: List[Song] = []

View File

@ -0,0 +1,29 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
# Shared properties
class PlaylistSongBase(BaseModel):
playlist_id: str
song_id: str
position: int
# Properties to receive via API on creation
class PlaylistSongCreate(PlaylistSongBase):
pass
# Properties to receive via API on update
class PlaylistSongUpdate(PlaylistSongBase):
position: Optional[int] = None
class PlaylistSong(PlaylistSongBase):
id: str
added_at: datetime
class Config:
from_attributes = True

46
app/schemas/song.py Normal file
View File

@ -0,0 +1,46 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
# Shared properties
class SongBase(BaseModel):
title: str
artist_id: str
album_id: Optional[str] = None
genre: Optional[str] = None
track_number: Optional[int] = None
duration: Optional[float] = None
# Properties to receive via API on creation
class SongCreate(SongBase):
file_path: str
# Properties to receive via API on update
class SongUpdate(SongBase):
title: Optional[str] = None
artist_id: Optional[str] = None
file_path: Optional[str] = None
class SongInDBBase(SongBase):
id: str
file_path: str
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# Additional properties to return via API
class Song(SongInDBBase):
pass
# Additional properties stored in DB
class SongInDB(SongInDBBase):
pass

50
app/schemas/tag.py Normal file
View File

@ -0,0 +1,50 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel
# Shared properties
class TagBase(BaseModel):
name: Optional[str] = None
color: Optional[str] = "#FFFFFF"
# Properties to receive via API on creation
class TagCreate(TagBase):
name: str
# Properties to receive via API on update
class TagUpdate(TagBase):
pass
class TagInDBBase(TagBase):
id: str
created_at: datetime
class Config:
from_attributes = True
# Additional properties to return via API
class Tag(TagInDBBase):
pass
# For response models that need to include notes
class TagWithNotes(Tag):
notes: List["NoteBasic"] = []
# Simple Note schema for use in TagWithNotes to avoid circular imports
class NoteBasic(BaseModel):
id: str
title: str
class Config:
from_attributes = True
TagWithNotes.update_forward_refs()

12
app/schemas/token.py Normal file
View File

@ -0,0 +1,12 @@
from typing import Optional
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str
class TokenPayload(BaseModel):
sub: Optional[str] = None

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

@ -0,0 +1,43 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
# Shared properties
class UserBase(BaseModel):
email: Optional[EmailStr] = None
full_name: Optional[str] = None
profile_image: Optional[str] = None
is_active: Optional[bool] = True
is_superuser: bool = False
# Properties to receive via API on creation
class UserCreate(UserBase):
email: EmailStr
password: str
# Properties to receive via API on update
class UserUpdate(UserBase):
password: Optional[str] = None
class UserInDBBase(UserBase):
id: str
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# Additional properties to return via API
class User(UserInDBBase):
pass
# Additional properties stored in DB
class UserInDB(UserInDBBase):
hashed_password: str

1
app/utils/__init__.py Normal file
View File

@ -0,0 +1 @@
# utils package

100
app/utils/file_storage.py Normal file
View File

@ -0,0 +1,100 @@
import os
import shutil
import uuid
from typing import Optional
from fastapi import HTTPException, UploadFile
from app.core.config import settings
def validate_file_size(file: UploadFile, max_size: int = settings.MAX_UPLOAD_SIZE) -> None:
"""Validate that the file is not larger than the maximum allowed size."""
# Move to start of file to ensure we read from the beginning
file.file.seek(0, os.SEEK_END)
file_size = file.file.tell()
# Reset file pointer to the beginning
file.file.seek(0)
if file_size > max_size:
raise HTTPException(
status_code=413,
detail=f"File size ({file_size} bytes) exceeds maximum allowed size ({max_size} bytes)"
)
def validate_audio_file(file: UploadFile) -> None:
"""Validate that the file is an audio file based on content type."""
allowed_content_types = [
"audio/mpeg", # MP3
"audio/wav", # WAV
"audio/ogg", # OGG
"audio/flac", # FLAC
"audio/aac", # AAC
]
if file.content_type not in allowed_content_types:
raise HTTPException(
status_code=415,
detail=f"Unsupported file type: {file.content_type}. Allowed types: {', '.join(allowed_content_types)}"
)
validate_file_size(file)
def validate_image_file(file: UploadFile) -> None:
"""Validate that the file is an image file based on content type."""
allowed_content_types = [
"image/jpeg", # JPEG, JPG
"image/png", # PNG
"image/gif", # GIF
"image/webp", # WEBP
]
if file.content_type not in allowed_content_types:
raise HTTPException(
status_code=415,
detail=f"Unsupported file type: {file.content_type}. Allowed types: {', '.join(allowed_content_types)}"
)
validate_file_size(file)
def save_upload_file(
upload_file: UploadFile,
destination_folder: str,
file_id: Optional[str] = None,
) -> str:
"""
Save the uploaded file to the specified destination folder.
Returns the relative path to the saved file.
"""
# Ensure destination directory exists
os.makedirs(destination_folder, exist_ok=True)
# Extract file extension
_, ext = os.path.splitext(upload_file.filename)
# Generate unique filename
filename = f"{file_id or str(uuid.uuid4())}{ext}"
file_path = os.path.join(destination_folder, filename)
# Save the file
with open(file_path, "wb") as buffer:
shutil.copyfileobj(upload_file.file, buffer)
return filename
def remove_file(file_path: str) -> bool:
"""
Remove a file from the filesystem.
Returns True if the file was successfully removed, False otherwise.
"""
try:
if os.path.exists(file_path):
os.remove(file_path)
return True
return False
except Exception:
return False

60
main.py Normal file
View File

@ -0,0 +1,60 @@
from pathlib import Path
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import api_router
from app.core.config import settings
app = FastAPI(
title="NoteTaker",
description="A Note Taking API with FastAPI and SQLite",
version="0.1.0",
openapi_url="/openapi.json",
docs_url="/docs",
redoc_url="/redoc",
)
# Set up CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API routes
app.include_router(api_router, prefix=settings.API_V1_STR)
# Create necessary directories
storage_path = Path("/app/storage")
storage_path.mkdir(parents=True, exist_ok=True)
db_path = storage_path / "db"
db_path.mkdir(parents=True, exist_ok=True)
@app.get("/", tags=["root"])
async def root():
"""
Root endpoint that returns basic app information.
"""
return {
"app_name": settings.PROJECT_NAME,
"description": "A Note Taking API with FastAPI and SQLite",
"version": "0.1.0",
"docs": "/docs",
"redoc": "/redoc",
"health": "/health",
"openapi": "/openapi.json",
}
@app.get("/health", tags=["health"])
async def health_check():
"""
Health check endpoint to verify the API is running properly.
"""
return {"status": "healthy", "service": settings.PROJECT_NAME}
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

22
pyproject.toml Normal file
View File

@ -0,0 +1,22 @@
[tool.ruff]
# Set the maximum line length to 88
line-length = 88
[tool.ruff.lint]
# Enable pycodestyle, pyflakes, and import sorting
select = ["E", "F", "I"]
# Allow autofix for all enabled rules (when `--fix`) is provided
fixable = ["ALL"]
# Exclude a variety of commonly ignored directories
exclude = [
".git",
".ruff_cache",
"__pypackages__",
"dist",
"venv",
]
[tool.ruff.lint.isort]
known-third-party = ["fastapi", "pydantic", "sqlalchemy", "alembic", "passlib", "jose"]

14
requirements.txt Normal file
View File

@ -0,0 +1,14 @@
fastapi>=0.104.0
uvicorn>=0.23.2
sqlalchemy>=2.0.0
alembic>=1.12.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
pydantic>=2.4.2
pydantic-settings>=2.0.3
python-dotenv>=1.0.0
ruff>=0.1.0
uuid>=1.30
fastapi-pagination>=0.12.10
email-validator>=2.0.0