Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Add audio_tracks table for multi-track support

Revision ID: 60f5233a9ec0
Revises: 434bd387074a
Create Date: 2025-07-12 19:03:42.738087

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "60f5233a9ec0"
down_revision: str | Sequence[str] | None = "434bd387074a"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("projects", "original_audio_s3_key")
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"projects",
sa.Column(
"original_audio_s3_key", sa.VARCHAR(), autoincrement=False, nullable=True
),
)
# ### end Alembic commands ###
113 changes: 40 additions & 73 deletions app/api/v1/endpoints/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,10 @@
from app.api.v1 import dependencies
from app.core.config import settings
from app.db import crud

# Import the SQLAlchemy model with an alias to prevent name conflicts
from app.db.models.project import Project as ProjectModel
from app.db.models.user import User
from app.schemas.project import (
Project,
ProjectCreate,
ProjectUpdate,
)
from app.schemas.audio_track import AudioTrackCreate
from app.schemas.project import Project, ProjectCreate, ProjectUpdate
from app.schemas.s3 import (
PresignedPostRequest,
PresignedPostResponse,
Expand All @@ -36,19 +31,11 @@ def create_project(
db: Session = Depends(dependencies.get_db),
current_user: User = Depends(dependencies.get_current_user),
) -> ProjectModel:
"""
Create a new project record for the current user.
This does NOT trigger processing; that happens after file upload is complete.
"""
logger.info(
"Request received to create project",
project_name=project_in.project_name,
user_id=current_user.id,
)
"""Create a new project record. Processing begins after file upload completion."""
logger.info("Creating project", user_id=current_user.id)
project = crud.project.create_with_owner(
db=db, obj_in=project_in, user_id=current_user.id
)
logger.info("Project record created successfully", project_id=str(project.id))
return project


Expand All @@ -60,28 +47,26 @@ def create_upload_url(
db: Session = Depends(dependencies.get_db),
current_user: User = Depends(dependencies.get_current_user),
) -> dict[str, Any]:
"""
Generate a pre-signed URL for a client to upload a file directly to S3.
"""
"""Generate pre-signed S3 URL for audio track upload."""
log = logger.bind(project_id=str(project_id), user_id=current_user.id)
log.info("Request received to generate S3 upload URL", filename=request_in.filename)

project = crud.project.get(db=db, id=project_id)

if not project or project.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Project not found")

log.info("Generating S3 upload URL", filename=request_in.filename)
object_key = f"uploads/{project.id}/{uuid.uuid4()}-{request_in.filename}"

presigned_data = s3_service.generate_presigned_post(
bucket_name=settings.AWS_S3_BUCKET_NAME, object_name=object_key
)

if not presigned_data:
log.error("Failed to generate S3 presigned POST URL")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Could not generate upload URL.",
)
return cast(dict[str, Any], presigned_data)
raise HTTPException(status_code=500, detail="Could not generate upload URL.")

# Cast to dict[str, Any] to satisfy MyPy
result = cast(dict[str, Any], presigned_data)
result["s3_key"] = object_key
return result


@router.post("/{project_id}/complete-upload", response_model=Project)
Expand All @@ -92,43 +77,35 @@ def complete_upload(
db: Session = Depends(dependencies.get_db),
current_user: User = Depends(dependencies.get_current_user),
) -> ProjectModel:
"""
Finalize a multipart upload and trigger the processing pipeline.
"""
"""Finalize upload, create track records, and trigger processing."""
log = logger.bind(project_id=str(project_id), user_id=current_user.id)
log.info(
"Request received to complete S3 upload", upload_id=completion_in.upload_id
)
log.info("Completing upload for tracks", track_count=len(completion_in.tracks))

project = crud.project.get(db=db, id=project_id)
if not project or project.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Project not found")

object_key = f"uploads/{project.id}/{completion_in.filename}"
parts_data = [part.model_dump() for part in completion_in.parts]

success = s3_service.complete_multipart_upload(
bucket_name=settings.AWS_S3_BUCKET_NAME,
object_name=object_key,
upload_id=completion_in.upload_id,
parts=parts_data,
)

if not success:
log.error("Failed to complete multipart upload in S3")
raise HTTPException(status_code=500, detail="Could not finalize file upload.")
# Create AudioTrack records for each uploaded file
for track_data in completion_in.tracks:
# Convert CompletedTrack to AudioTrackCreate with only valid fields
track_create = AudioTrackCreate(
s3_key=track_data.s3_key,
speaker_label=track_data.speaker_label,
)
crud.audio_track.create_with_project(
db=db, obj_in=track_create, project_id=uuid.UUID(str(project.id))
)

updated_project = crud.project.update(
db=db,
db_obj=project,
obj_in={"original_audio_s3_key": object_key, "status": "PROCESSING"},
# Update project status to trigger processing
project = crud.project.update(
db=db, db_obj=project, obj_in={"status": "PROCESSING"}
)
log.info("Project status updated to PROCESSING")
db.commit()

post_upload_processing.delay(str(project.id))
log.info("Dispatched background processing task")

return updated_project
return project


@router.get("/", response_model=list[Project])
Expand All @@ -153,15 +130,10 @@ def read_project(
db: Session = Depends(dependencies.get_db),
current_user: User = Depends(dependencies.get_current_user),
) -> ProjectModel:
"""Get a specific project by its ID, ensuring it belongs to the current user."""
log = logger.bind(project_id=str(project_id), user_id=current_user.id)
log.info("Request received to get project by ID")

"""Retrieve a specific project by ID."""
project = crud.project.get(db=db, id=project_id)
if not project or project.user_id != current_user.id:
log.warning("User attempted to access a project they do not own")
raise HTTPException(status_code=404, detail="Project not found")

return project


Expand All @@ -173,17 +145,13 @@ def update_project(
db: Session = Depends(dependencies.get_db),
current_user: User = Depends(dependencies.get_current_user),
) -> ProjectModel:
"""Update a project, ensuring it belongs to the current user."""
log = logger.bind(project_id=str(project_id), user_id=current_user.id)
log.info("Request received to update project")

"""Update project properties."""
project = crud.project.get(db=db, id=project_id)
if not project or project.user_id != current_user.id:
log.warning("User attempted to update a project they do not own")
raise HTTPException(status_code=404, detail="Project not found")

updated_project = crud.project.update(db=db, db_obj=project, obj_in=project_in)
log.info("Project updated successfully")
logger.info("Project updated successfully", project_id=project_id)
return updated_project


Expand All @@ -194,15 +162,14 @@ def delete_project(
db: Session = Depends(dependencies.get_db),
current_user: User = Depends(dependencies.get_current_user),
) -> ProjectModel:
"""Delete a project, ensuring it belongs to the current user."""
log = logger.bind(project_id=str(project_id), user_id=current_user.id)
log.info("Request received to delete project")

"""Delete a project and associated resources."""
project = crud.project.get(db=db, id=project_id)
if not project or project.user_id != current_user.id:
log.warning("User attempted to delete a project they do not own")
raise HTTPException(status_code=404, detail="Project not found")

deleted_project = crud.project.remove(db=db, id=project_id)
log.info("Project deleted successfully")
return cast(ProjectModel, deleted_project)
if not deleted_project:
raise HTTPException(status_code=500, detail="Failed to delete project")

logger.info("Project deleted successfully", project_id=project_id)
return deleted_project
4 changes: 3 additions & 1 deletion app/db/crud/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from .audio_track import audio_track
from .project import project
from .transcript_segment import transcript_segment
from .user import user

__all__ = ["project", "user"]
__all__ = ["project", "user", "audio_track", "transcript_segment"]
24 changes: 24 additions & 0 deletions app/db/crud/audio_track.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import uuid

from sqlalchemy.orm import Session

from app.db.models.audio_track import AudioTrack
from app.schemas.audio_track import AudioTrackCreate, AudioTrackUpdate

from .base import CRUDBase


class CRUDAudioTrack(CRUDBase[AudioTrack, AudioTrackCreate, AudioTrackUpdate]):
def create_with_project(
self, db: Session, *, obj_in: AudioTrackCreate, project_id: uuid.UUID
) -> AudioTrack:
"""
Create a new audio track and assign it to a project.
"""
db_obj = self.model(**obj_in.model_dump(), project_id=project_id)
db.add(db_obj)
db.flush()
return db_obj


audio_track = CRUDAudioTrack(AudioTrack)
31 changes: 31 additions & 0 deletions app/db/crud/transcript_segment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import uuid

from sqlalchemy.orm import Session

from app.db.models.transcript_segment import TranscriptSegment
from app.schemas.transcript_segment import (
TranscriptSegmentCreate,
TranscriptSegmentUpdate,
)

from .base import CRUDBase


class CRUDTranscriptSegment(
CRUDBase[TranscriptSegment, TranscriptSegmentCreate, TranscriptSegmentUpdate]
):
def create_with_project(
self, db: Session, *, obj_in: TranscriptSegmentCreate, project_id: uuid.UUID
) -> TranscriptSegment:
"""
Create a new transcript segment and assign it to a project.
"""
# Create the SQLAlchemy model instance, including the project_id
db_obj = self.model(**obj_in.model_dump(), project_id=project_id)
db.add(db_obj)
db.flush() # Send the change to the DB without committing the transaction
db.refresh(db_obj)
return db_obj


transcript_segment = CRUDTranscriptSegment(TranscriptSegment)
4 changes: 3 additions & 1 deletion app/db/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from .audio_track import AudioTrack
from .base import Base
from .project import Project
from .transcript_segment import TranscriptSegment
from .user import User

__all__ = ["Base", "Project", "User"]
__all__ = ["Base", "Project", "User", "AudioTrack", "TranscriptSegment"]
19 changes: 19 additions & 0 deletions app/db/models/audio_track.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import uuid

from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship

from .base import Base


class AudioTrack(Base):
__tablename__ = "audio_tracks"

id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
project_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("projects.id"))
s3_key: Mapped[str] = mapped_column(String, nullable=False)
speaker_label: Mapped[str] = mapped_column(
String, nullable=False
) # e.g., "Host", "Guest 1"

project = relationship("Project", back_populates="audio_tracks")
4 changes: 3 additions & 1 deletion app/db/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ class Project(Base):
project_name = Column(String(255), nullable=False)
status = Column(String(50), nullable=False, default="UPLOADING")
language_code = Column(String(10))
original_audio_s3_key: Mapped[str | None] = mapped_column(String, nullable=True)
audio_tracks = relationship(
"AudioTrack", back_populates="project", cascade="all, delete-orphan"
)
final_audio_s3_key: Mapped[str | None] = mapped_column(String, nullable=True)
error_message = Column(Text)

Expand Down
23 changes: 23 additions & 0 deletions app/db/models/transcript_segment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import uuid

from sqlalchemy import Float, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship

from .base import Base


class TranscriptSegment(Base):
__tablename__ = "transcript_segments"

id: Mapped[int] = mapped_column(primary_key=True, index=True)
project_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("projects.id"))

start_time: Mapped[float] = mapped_column(Float, nullable=False)
end_time: Mapped[float] = mapped_column(Float, nullable=False)
original_text: Mapped[str] = mapped_column(Text, nullable=False)
speaker_label: Mapped[str] = mapped_column(String(100), nullable=False)

# This field will be used later for the editing feature
edited_text: Mapped[str | None] = mapped_column(Text, nullable=True)

project = relationship("Project")
Loading
Loading