Skip to content
Draft
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
24 changes: 24 additions & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
FROM mcr.microsoft.com/devcontainers/python:1-3.13-bookworm

ARG NODE_VERSION=20.20.1

RUN rm -f /etc/apt/sources.list.d/yarn.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl postgresql-client redis-tools xz-utils \
&& rm -rf /var/lib/apt/lists/* \
&& ARCH="$(dpkg --print-architecture)" \
&& case "$ARCH" in \
amd64) NODE_ARCH='x64' ;; \
arm64) NODE_ARCH='arm64' ;; \
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
esac \
&& curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" -o /tmp/node.tar.xz \
&& tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 --no-same-owner \
&& rm /tmp/node.tar.xz \
&& python -m pip install --no-cache-dir poetry==1.8.3 \
&& corepack enable \
&& corepack prepare pnpm@10.27.0 --activate

WORKDIR /workspace

USER vscode
42 changes: 42 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "chrono-guide",
"dockerComposeFile": [
"../docker-compose.yml",
"../docker-compose.override.yml"
],
"service": "workspace",
"runServices": [
"workspace",
"db",
"redis",
"api",
"frontend",
"celery-worker",
"celery-beat"
],
"workspaceFolder": "/workspace",
"shutdownAction": "stopCompose",
"remoteUser": "vscode",
"postCreateCommand": "bash -lc 'cd /workspace/backend && poetry install --no-interaction && cd /workspace/frontend && pnpm install'",
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance",
"charliermarsh.ruff",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss"
],
"settings": {
"python.defaultInterpreterPath": "/workspace/backend/.venv/bin/python",
"eslint.workingDirectories": [
"frontend"
]
}
}
},
"containerEnv": {
"DATABASE_URL": "postgresql+psycopg://chrono:chrono@db:5432/chrono"
}
}
6 changes: 5 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,9 @@
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.analysis.typeCheckingMode": "strict",
"python.analysis.extraPaths": ["${workspaceFolder}/backend"]
"python.analysis.extraPaths": [
"${workspaceFolder}/backend"
],
"python-envs.defaultEnvManager": "ms-python.python:poetry",
"python-envs.defaultPackageManager": "ms-python.python:poetry"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""add calendar connections table

Revision ID: 1a248b4f780b
Revises: 4830093f7fc4
Create Date: 2026-02-28 13:57:03.648000

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
import sqlmodel


# revision identifiers, used by Alembic.
revision: str = '1a248b4f780b'
down_revision: Union[str, None] = '4830093f7fc4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('calendar_connections',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('connection_type', sa.String(length=30), nullable=False),
sa.Column('label', sa.String(length=100), nullable=False),
sa.Column('calendar_url', sa.Text(), nullable=False),
sa.Column('username', sa.String(length=255), nullable=True),
sa.Column('key_id', sa.String(length=20), server_default='V1_KEY', nullable=True),
sa.Column('secret', sa.Text(), nullable=True),
sa.Column('last_error', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('last_synced_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('last_error_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('secret_updated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('calendar_connections')
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""adjust schedule item for external sync

Revision ID: 76d26839d559
Revises: 1a248b4f780b
Create Date: 2026-03-13 07:43:53.387302

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '76d26839d559'
down_revision: Union[str, None] = '1a248b4f780b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('schedule_items', sa.Column('external_id', sa.String(length=255), nullable=True))
op.add_column('schedule_items', sa.Column('connection_id', sa.Integer(), nullable=True))
op.create_index('unique_external_id', 'schedule_items', ['user_id', 'source', 'external_id', 'connection_id'], unique=True, postgresql_where=sa.text('external_id IS NOT NULL AND connection_id IS NOT NULL'))
op.create_foreign_key(
'fk_schedule_items_connection_id_calendar_connections',
'schedule_items',
'calendar_connections',
['connection_id'],
['id'],
)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(
'fk_schedule_items_connection_id_calendar_connections',
'schedule_items',
type_='foreignkey',
)
op.drop_index('unique_external_id', table_name='schedule_items', postgresql_where=sa.text('external_id IS NOT NULL AND connection_id IS NOT NULL'))
op.drop_column('schedule_items', 'connection_id')
op.drop_column('schedule_items', 'external_id')
# ### end Alembic commands ###
7 changes: 7 additions & 0 deletions backend/app/api/routers/calendar/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from fastapi import APIRouter

from .connections import router as connections_router

router: APIRouter = APIRouter(prefix="/calendar", tags=["calendar"])

router.include_router(connections_router)
21 changes: 21 additions & 0 deletions backend/app/api/routers/calendar/connections.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends
from sqlmodel import Session

import app.crud.calendar_connection_crud as calendar_connection_crud
from app.core.auth import get_current_user_id
from app.core.db import get_db
from app.schemas.calendar_connections import CalendarConnectionCreate

router: APIRouter = APIRouter(prefix="/connections", tags=["connections"])


@router.post("/")
async def add_calendar_connection(
calendar_connection: CalendarConnectionCreate,
user_id: int = Depends(get_current_user_id),
session: Session = Depends(get_db),
):
"""Add a calendar connection for the user."""
return calendar_connection_crud.create_calendar_connection(
calendar_connection=calendar_connection, user_id=user_id, session=session
)
3 changes: 2 additions & 1 deletion backend/app/api/routers/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
from app.models.task import Task
from app.schemas.schedule_item import ScheduleItemCreate, ScheduleItemResponse
from app.schemas.schedule_requests import ScheduleGenerateRequest
from app.services.caldav_service import export_calendar_from_schedule_items
from app.services.greedy_scheduler import GreedyScheduler
from app.services.ical_service import export_calendar_from_schedule_items
from app.services.protocols import ChronoScheduler
from app.services.scheduling_types import SchedulingConfig, SchedulingResponse
from app.services.scheduling_utils import schedule_blocks_to_schedule_items
Expand All @@ -31,6 +31,7 @@ def get_task_scheduler() -> ChronoScheduler:
return GreedyScheduler()


# TODO: move this export endpoint into the calendar folder
@router.get("/export")
async def export_schedule(
user_id: int = Depends(get_current_user_id), session: Session = Depends(get_db)
Expand Down
3 changes: 2 additions & 1 deletion backend/app/app_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from fastapi.responses import JSONResponse
from pydantic import ValidationError

from app.api.routers import health, schedule, settings, tasks, users
from app.api.routers import calendar, health, schedule, settings, tasks, users
from app.core.config import APP_NAME, APP_VERSION
from app.core.db import init_db
from app.core.exceptions import NotFoundError, SystemError
Expand Down Expand Up @@ -67,6 +67,7 @@ async def system_error_handler(_request: Request, exc: SystemError) -> JSONRespo
app.include_router(users.router)
app.include_router(settings.router)
app.include_router(schedule.router)
app.include_router(calendar.router)
assert app.exception_handlers[NotFoundError] is not_found_error_handler
assert app.exception_handlers[ValidationError] is validation_error_handler
assert app.exception_handlers[SystemError] is system_error_handler
Expand Down
11 changes: 9 additions & 2 deletions backend/app/celery_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@

redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")

celery_app = Celery(
celery_app: Celery = Celery(
"chrono_guide",
broker=redis_url,
backend=redis_url,
include=["app.tasks.ingestion_tasks"],
include=["app.tasks.ingestion_tasks", "app.tasks.periodic_sync"],
)

celery_app.conf.beat_schedule = {
"periodic-sync-every-15-minutes": {
"task": "app.tasks.periodic_sync.periodic_sync",
"schedule": 900.0, # every 15 minutes
},
}

celery_app.conf.update(
task_serializer="json",
accept_content=["json"],
Expand Down
30 changes: 30 additions & 0 deletions backend/app/crud/calendar_connection_crud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from sqlmodel import Session, select

from app.core.timezone import now_utc
from app.models.calender_connections import CalendarConnection
from app.schemas.calendar_connections import CalendarConnectionCreate


def create_calendar_connection(
calendar_connection: CalendarConnectionCreate, user_id: int, session: Session
) -> CalendarConnection:
calendar_connection_model: CalendarConnection = CalendarConnection(
**calendar_connection.model_dump(),
user_id=user_id,
secret_updated_at=now_utc() if calendar_connection.secret else None,
)
calendar_connection_model.user_id = user_id
session.add(calendar_connection_model)
session.flush()
session.refresh(calendar_connection_model)
return calendar_connection_model


def get_calendar_connections_by_user_id(
user_id: int, session: Session
) -> list[CalendarConnection]:
return list(
session.exec(
select(CalendarConnection).where(CalendarConnection.user_id == user_id)
).all()
)
58 changes: 58 additions & 0 deletions backend/app/crud/schedule_item_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ def create_schedule_items_from_blocks(
source=block.source,
title=block.title,
description=block.description,
external_id=None,
connection_id=None,
)
schedule_items.append(schedule_item)
session.add(schedule_item)
Expand All @@ -59,3 +61,59 @@ def create_schedule_items_from_blocks(
session.refresh(item)

return schedule_items


def sync_schedule_items_to_db(
schedule_items: list[ScheduleItemCreate], user_id: int, session: Session
) -> list[ScheduleItem]:
"""Sync schedule items for a user. This will create new items, update existing ones, and delete items that are no longer present."""
existing_items = get_user_schedule_items(user_id=user_id, session=session)
existing_items_map: dict[tuple[str, str, int], ScheduleItem] = {
(item.source, item.external_id, item.connection_id): item
for item in existing_items
if item.external_id is not None and item.connection_id is not None
}

new_items: list[ScheduleItem] = []
for item in schedule_items:
if (
item.external_id is None
or item.connection_id is None
or item.source is None
):
continue
key: tuple[str, str, int] = (item.source, item.external_id, item.connection_id)
if key in existing_items_map:
# Update existing item
existing_item = existing_items_map[key]
existing_item.start_time = item.start_time
existing_item.end_time = item.end_time
existing_item.title = item.title
existing_item.description = item.description
new_items.append(existing_item)
del existing_items_map[key]
else:
# Create new item
new_item = ScheduleItem(
user_id=user_id,
task_id=item.task_id,
start_time=item.start_time,
end_time=item.end_time,
source=item.source,
title=item.title,
description=item.description,
external_id=item.external_id,
connection_id=item.connection_id,
)
session.add(new_item)
new_items.append(new_item)

# Delete items that are no longer present
for remaining_item in existing_items_map.values():
session.delete(remaining_item)

session.flush()
for item in new_items:
session.refresh(item)

return new_items
4 changes: 4 additions & 0 deletions backend/app/crud/user_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,7 @@ def login(user: UserLogin, session: Session) -> dict[str, str]:
data={"sub": str(existing_user.id), "email": existing_user.email}
)
return {"access_token": access_token, "token_type": "bearer"}


def get_users(session: Session) -> list[User]:
return list(session.exec(select(User)).all())
2 changes: 2 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .temp_upload import TempUpload
from .user import User
from .user_setting import UserSetting
from .calender_connections import CalendarConnection

__all__ = [
"Task",
Expand All @@ -14,5 +15,6 @@
"ScheduleItem",
"UserSetting",
"TempUpload",
"CalendarConnection",
"CeleryJob",
]
Loading
Loading