mirror of
https://github.com/RichieCahill/dotfiles.git
synced 2026-04-17 04:58:19 -04:00
added Contact api and data model
This commit is contained in:
@@ -1,49 +0,0 @@
|
|||||||
"""base.
|
|
||||||
|
|
||||||
Revision ID: 7bd2bdc231dc
|
|
||||||
Revises:
|
|
||||||
Create Date: 2026-01-10 13:12:13.764467
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
from python.orm import RichieBase
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = "7bd2bdc231dc"
|
|
||||||
down_revision: str | None = None
|
|
||||||
branch_labels: str | Sequence[str] | None = None
|
|
||||||
depends_on: str | Sequence[str] | None = None
|
|
||||||
|
|
||||||
schema = RichieBase.schema_name
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Upgrade."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table(
|
|
||||||
"temp",
|
|
||||||
sa.Column("name", sa.String(length=255), nullable=False),
|
|
||||||
sa.Column("id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_temp")),
|
|
||||||
schema=schema,
|
|
||||||
)
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_table("temp", schema=schema)
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""created contact api.
|
||||||
|
|
||||||
|
Revision ID: edd7dd61a3d2
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-01-11 15:45:59.909266
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from python.orm import RichieBase
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "edd7dd61a3d2"
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
schema = RichieBase.schema_name
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"contact",
|
||||||
|
sa.Column("name", sa.String(), nullable=False),
|
||||||
|
sa.Column("age", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("bio", sa.String(), nullable=True),
|
||||||
|
sa.Column("current_job", sa.String(), nullable=True),
|
||||||
|
sa.Column("gender", sa.String(), nullable=True),
|
||||||
|
sa.Column("goals", sa.String(), nullable=True),
|
||||||
|
sa.Column("legal_name", sa.String(), nullable=True),
|
||||||
|
sa.Column("profile_pic", sa.String(), nullable=True),
|
||||||
|
sa.Column("safe_conversation_starters", sa.String(), nullable=True),
|
||||||
|
sa.Column("self_sufficiency_score", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("social_structure_style", sa.String(), nullable=True),
|
||||||
|
sa.Column("ssn", sa.String(), nullable=True),
|
||||||
|
sa.Column("suffix", sa.String(), nullable=True),
|
||||||
|
sa.Column("timezone", sa.String(), nullable=True),
|
||||||
|
sa.Column("topics_to_avoid", sa.String(), nullable=True),
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_contact")),
|
||||||
|
schema=schema,
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"need",
|
||||||
|
sa.Column("name", sa.String(), nullable=False),
|
||||||
|
sa.Column("description", sa.String(), nullable=True),
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_need")),
|
||||||
|
schema=schema,
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"contact_need",
|
||||||
|
sa.Column("contact_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("need_id", sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["contact_id"],
|
||||||
|
[f"{schema}.contact.id"],
|
||||||
|
name=op.f("fk_contact_need_contact_id_contact"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["need_id"], [f"{schema}.need.id"], name=op.f("fk_contact_need_need_id_need"), ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("contact_id", "need_id", name=op.f("pk_contact_need")),
|
||||||
|
schema=schema,
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"contact_relationship",
|
||||||
|
sa.Column("contact_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("related_contact_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("relationship_type", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("closeness_weight", sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["contact_id"],
|
||||||
|
[f"{schema}.contact.id"],
|
||||||
|
name=op.f("fk_contact_relationship_contact_id_contact"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["related_contact_id"],
|
||||||
|
[f"{schema}.contact.id"],
|
||||||
|
name=op.f("fk_contact_relationship_related_contact_id_contact"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("contact_id", "related_contact_id", name=op.f("pk_contact_relationship")),
|
||||||
|
schema=schema,
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table("contact_relationship", schema=schema)
|
||||||
|
op.drop_table("contact_need", schema=schema)
|
||||||
|
op.drop_table("need", schema=schema)
|
||||||
|
op.drop_table("contact", schema=schema)
|
||||||
|
# ### end Alembic commands ###
|
||||||
1
python/api/__init__.py
Normal file
1
python/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""FastAPI applications."""
|
||||||
536
python/api/contact_api.py
Normal file
536
python/api/contact_api.py
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
"""FastAPI interface for Contact database."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator, Iterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from python.orm.base import get_postgres_engine
|
||||||
|
from python.orm.contact import Contact, ContactRelationship, Need, RelationshipType
|
||||||
|
|
||||||
|
FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend" / "dist"
|
||||||
|
|
||||||
|
|
||||||
|
class NeedBase(BaseModel):
|
||||||
|
"""Base schema for Need."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NeedCreate(NeedBase):
|
||||||
|
"""Schema for creating a Need."""
|
||||||
|
|
||||||
|
|
||||||
|
class NeedResponse(NeedBase):
|
||||||
|
"""Schema for Need response."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ContactRelationshipCreate(BaseModel):
|
||||||
|
"""Schema for creating a contact relationship."""
|
||||||
|
|
||||||
|
related_contact_id: int
|
||||||
|
relationship_type: RelationshipType
|
||||||
|
closeness_weight: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContactRelationshipUpdate(BaseModel):
|
||||||
|
"""Schema for updating a contact relationship."""
|
||||||
|
|
||||||
|
relationship_type: RelationshipType | None = None
|
||||||
|
closeness_weight: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContactRelationshipResponse(BaseModel):
|
||||||
|
"""Schema for contact relationship response."""
|
||||||
|
|
||||||
|
contact_id: int
|
||||||
|
related_contact_id: int
|
||||||
|
relationship_type: str
|
||||||
|
closeness_weight: int
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class RelationshipTypeInfo(BaseModel):
|
||||||
|
"""Information about a relationship type."""
|
||||||
|
|
||||||
|
value: str
|
||||||
|
display_name: str
|
||||||
|
default_weight: int
|
||||||
|
|
||||||
|
|
||||||
|
class GraphNode(BaseModel):
|
||||||
|
"""Node in the relationship graph."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
current_job: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GraphEdge(BaseModel):
|
||||||
|
"""Edge in the relationship graph."""
|
||||||
|
|
||||||
|
source: int
|
||||||
|
target: int
|
||||||
|
relationship_type: str
|
||||||
|
closeness_weight: int
|
||||||
|
|
||||||
|
|
||||||
|
class GraphData(BaseModel):
|
||||||
|
"""Complete graph data for visualization."""
|
||||||
|
|
||||||
|
nodes: list[GraphNode]
|
||||||
|
edges: list[GraphEdge]
|
||||||
|
|
||||||
|
|
||||||
|
class ContactBase(BaseModel):
|
||||||
|
"""Base schema for Contact."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
age: int | None = None
|
||||||
|
bio: str | None = None
|
||||||
|
current_job: str | None = None
|
||||||
|
gender: str | None = None
|
||||||
|
goals: str | None = None
|
||||||
|
legal_name: str | None = None
|
||||||
|
profile_pic: str | None = None
|
||||||
|
safe_conversation_starters: str | None = None
|
||||||
|
self_sufficiency_score: int | None = None
|
||||||
|
social_structure_style: str | None = None
|
||||||
|
ssn: str | None = None
|
||||||
|
suffix: str | None = None
|
||||||
|
timezone: str | None = None
|
||||||
|
topics_to_avoid: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContactCreate(ContactBase):
|
||||||
|
"""Schema for creating a Contact."""
|
||||||
|
|
||||||
|
need_ids: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ContactUpdate(BaseModel):
|
||||||
|
"""Schema for updating a Contact."""
|
||||||
|
|
||||||
|
name: str | None = None
|
||||||
|
age: int | None = None
|
||||||
|
bio: str | None = None
|
||||||
|
current_job: str | None = None
|
||||||
|
gender: str | None = None
|
||||||
|
goals: str | None = None
|
||||||
|
legal_name: str | None = None
|
||||||
|
profile_pic: str | None = None
|
||||||
|
safe_conversation_starters: str | None = None
|
||||||
|
self_sufficiency_score: int | None = None
|
||||||
|
social_structure_style: str | None = None
|
||||||
|
ssn: str | None = None
|
||||||
|
suffix: str | None = None
|
||||||
|
timezone: str | None = None
|
||||||
|
topics_to_avoid: str | None = None
|
||||||
|
need_ids: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContactResponse(ContactBase):
|
||||||
|
"""Schema for Contact response with relationships."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
needs: list[NeedResponse] = []
|
||||||
|
related_to: list[ContactRelationshipResponse] = []
|
||||||
|
related_from: list[ContactRelationshipResponse] = []
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ContactListResponse(ContactBase):
|
||||||
|
"""Schema for Contact list response."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseSession:
|
||||||
|
"""Database session manager."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize with no engine."""
|
||||||
|
self._engine: Engine | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def engine(self) -> Engine:
|
||||||
|
"""Get or create the database engine."""
|
||||||
|
if self._engine is None:
|
||||||
|
self._engine = get_postgres_engine()
|
||||||
|
return self._engine
|
||||||
|
|
||||||
|
def get_session(self) -> Iterator[Session]:
|
||||||
|
"""Yield a database session."""
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
def dispose(self) -> None:
|
||||||
|
"""Dispose of the engine."""
|
||||||
|
if self._engine is not None:
|
||||||
|
self._engine.dispose()
|
||||||
|
self._engine = None
|
||||||
|
|
||||||
|
|
||||||
|
db_manager = DatabaseSession()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Iterator[Session]:
|
||||||
|
"""Get database session dependency."""
|
||||||
|
yield from db_manager.get_session()
|
||||||
|
|
||||||
|
|
||||||
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||||
|
"""Manage application lifespan."""
|
||||||
|
yield
|
||||||
|
db_manager.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Contact Database API", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
# API routes
|
||||||
|
@app.post("/api/needs", response_model=NeedResponse)
|
||||||
|
def create_need(need: NeedCreate, db: DbSession) -> Need:
|
||||||
|
"""Create a new need."""
|
||||||
|
db_need = Need(name=need.name, description=need.description)
|
||||||
|
db.add(db_need)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_need)
|
||||||
|
return db_need
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/needs", response_model=list[NeedResponse])
|
||||||
|
def list_needs(db: DbSession) -> list[Need]:
|
||||||
|
"""List all needs."""
|
||||||
|
return list(db.scalars(select(Need)).all())
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/needs/{need_id}", response_model=NeedResponse)
|
||||||
|
def get_need(need_id: int, db: DbSession) -> Need:
|
||||||
|
"""Get a need by ID."""
|
||||||
|
need = db.get(Need, need_id)
|
||||||
|
if not need:
|
||||||
|
raise HTTPException(status_code=404, detail="Need not found")
|
||||||
|
return need
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/needs/{need_id}")
|
||||||
|
def delete_need(need_id: int, db: DbSession) -> dict[str, bool]:
|
||||||
|
"""Delete a need by ID."""
|
||||||
|
need = db.get(Need, need_id)
|
||||||
|
if not need:
|
||||||
|
raise HTTPException(status_code=404, detail="Need not found")
|
||||||
|
db.delete(need)
|
||||||
|
db.commit()
|
||||||
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/contacts", response_model=ContactResponse)
|
||||||
|
def create_contact(contact: ContactCreate, db: DbSession) -> Contact:
|
||||||
|
"""Create a new contact."""
|
||||||
|
need_ids = contact.need_ids
|
||||||
|
contact_data = contact.model_dump(exclude={"need_ids"})
|
||||||
|
db_contact = Contact(**contact_data)
|
||||||
|
|
||||||
|
if need_ids:
|
||||||
|
needs = list(db.scalars(select(Need).where(Need.id.in_(need_ids))).all())
|
||||||
|
db_contact.needs = needs
|
||||||
|
|
||||||
|
db.add(db_contact)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_contact)
|
||||||
|
return db_contact
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/contacts", response_model=list[ContactListResponse])
|
||||||
|
def list_contacts(
|
||||||
|
db: DbSession,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[Contact]:
|
||||||
|
"""List all contacts with pagination."""
|
||||||
|
return list(db.scalars(select(Contact).offset(skip).limit(limit)).all())
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/contacts/{contact_id}", response_model=ContactResponse)
|
||||||
|
def get_contact(contact_id: int, db: DbSession) -> Contact:
|
||||||
|
"""Get a contact by ID with all relationships."""
|
||||||
|
contact = db.scalar(
|
||||||
|
select(Contact)
|
||||||
|
.where(Contact.id == contact_id)
|
||||||
|
.options(
|
||||||
|
selectinload(Contact.needs),
|
||||||
|
selectinload(Contact.related_to),
|
||||||
|
selectinload(Contact.related_from),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not contact:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
return contact
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/contacts/{contact_id}", response_model=ContactResponse)
|
||||||
|
def update_contact(
|
||||||
|
contact_id: int,
|
||||||
|
contact: ContactUpdate,
|
||||||
|
db: DbSession,
|
||||||
|
) -> Contact:
|
||||||
|
"""Update a contact by ID."""
|
||||||
|
db_contact = db.get(Contact, contact_id)
|
||||||
|
if not db_contact:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
|
||||||
|
update_data = contact.model_dump(exclude_unset=True)
|
||||||
|
need_ids = update_data.pop("need_ids", None)
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(db_contact, key, value)
|
||||||
|
|
||||||
|
if need_ids is not None:
|
||||||
|
needs = list(db.scalars(select(Need).where(Need.id.in_(need_ids))).all())
|
||||||
|
db_contact.needs = needs
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_contact)
|
||||||
|
return db_contact
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/contacts/{contact_id}")
|
||||||
|
def delete_contact(contact_id: int, db: DbSession) -> dict[str, bool]:
|
||||||
|
"""Delete a contact by ID."""
|
||||||
|
contact = db.get(Contact, contact_id)
|
||||||
|
if not contact:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
db.delete(contact)
|
||||||
|
db.commit()
|
||||||
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/contacts/{contact_id}/needs/{need_id}")
|
||||||
|
def add_need_to_contact(
|
||||||
|
contact_id: int,
|
||||||
|
need_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
) -> dict[str, bool]:
|
||||||
|
"""Add a need to a contact."""
|
||||||
|
contact = db.get(Contact, contact_id)
|
||||||
|
if not contact:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
|
||||||
|
need = db.get(Need, need_id)
|
||||||
|
if not need:
|
||||||
|
raise HTTPException(status_code=404, detail="Need not found")
|
||||||
|
|
||||||
|
if need not in contact.needs:
|
||||||
|
contact.needs.append(need)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"added": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/contacts/{contact_id}/needs/{need_id}")
|
||||||
|
def remove_need_from_contact(
|
||||||
|
contact_id: int,
|
||||||
|
need_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
) -> dict[str, bool]:
|
||||||
|
"""Remove a need from a contact."""
|
||||||
|
contact = db.get(Contact, contact_id)
|
||||||
|
if not contact:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
|
||||||
|
need = db.get(Need, need_id)
|
||||||
|
if not need:
|
||||||
|
raise HTTPException(status_code=404, detail="Need not found")
|
||||||
|
|
||||||
|
if need in contact.needs:
|
||||||
|
contact.needs.remove(need)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"removed": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/api/contacts/{contact_id}/relationships",
|
||||||
|
response_model=ContactRelationshipResponse,
|
||||||
|
)
|
||||||
|
def add_contact_relationship(
|
||||||
|
contact_id: int,
|
||||||
|
relationship: ContactRelationshipCreate,
|
||||||
|
db: DbSession,
|
||||||
|
) -> ContactRelationship:
|
||||||
|
"""Add a relationship between two contacts."""
|
||||||
|
contact = db.get(Contact, contact_id)
|
||||||
|
if not contact:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
|
||||||
|
related_contact = db.get(Contact, relationship.related_contact_id)
|
||||||
|
if not related_contact:
|
||||||
|
raise HTTPException(status_code=404, detail="Related contact not found")
|
||||||
|
|
||||||
|
if contact_id == relationship.related_contact_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot relate contact to itself")
|
||||||
|
|
||||||
|
# Use provided weight or default from relationship type
|
||||||
|
weight = relationship.closeness_weight
|
||||||
|
if weight is None:
|
||||||
|
weight = relationship.relationship_type.default_weight
|
||||||
|
|
||||||
|
db_relationship = ContactRelationship(
|
||||||
|
contact_id=contact_id,
|
||||||
|
related_contact_id=relationship.related_contact_id,
|
||||||
|
relationship_type=relationship.relationship_type.value,
|
||||||
|
closeness_weight=weight,
|
||||||
|
)
|
||||||
|
db.add(db_relationship)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_relationship)
|
||||||
|
return db_relationship
|
||||||
|
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/api/contacts/{contact_id}/relationships",
|
||||||
|
response_model=list[ContactRelationshipResponse],
|
||||||
|
)
|
||||||
|
def get_contact_relationships(
|
||||||
|
contact_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
) -> list[ContactRelationship]:
|
||||||
|
"""Get all relationships for a contact."""
|
||||||
|
contact = db.get(Contact, contact_id)
|
||||||
|
if not contact:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
|
||||||
|
outgoing = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ContactRelationship).where(ContactRelationship.contact_id == contact_id)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
incoming = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ContactRelationship).where(ContactRelationship.related_contact_id == contact_id)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
return outgoing + incoming
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch(
|
||||||
|
"/api/contacts/{contact_id}/relationships/{related_contact_id}",
|
||||||
|
response_model=ContactRelationshipResponse,
|
||||||
|
)
|
||||||
|
def update_contact_relationship(
|
||||||
|
contact_id: int,
|
||||||
|
related_contact_id: int,
|
||||||
|
update: ContactRelationshipUpdate,
|
||||||
|
db: DbSession,
|
||||||
|
) -> ContactRelationship:
|
||||||
|
"""Update a relationship between two contacts."""
|
||||||
|
relationship = db.scalar(
|
||||||
|
select(ContactRelationship).where(
|
||||||
|
ContactRelationship.contact_id == contact_id,
|
||||||
|
ContactRelationship.related_contact_id == related_contact_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not relationship:
|
||||||
|
raise HTTPException(status_code=404, detail="Relationship not found")
|
||||||
|
|
||||||
|
if update.relationship_type is not None:
|
||||||
|
relationship.relationship_type = update.relationship_type.value
|
||||||
|
if update.closeness_weight is not None:
|
||||||
|
relationship.closeness_weight = update.closeness_weight
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(relationship)
|
||||||
|
return relationship
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/contacts/{contact_id}/relationships/{related_contact_id}")
|
||||||
|
def remove_contact_relationship(
|
||||||
|
contact_id: int,
|
||||||
|
related_contact_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
) -> dict[str, bool]:
|
||||||
|
"""Remove a relationship between two contacts."""
|
||||||
|
relationship = db.scalar(
|
||||||
|
select(ContactRelationship).where(
|
||||||
|
ContactRelationship.contact_id == contact_id,
|
||||||
|
ContactRelationship.related_contact_id == related_contact_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not relationship:
|
||||||
|
raise HTTPException(status_code=404, detail="Relationship not found")
|
||||||
|
|
||||||
|
db.delete(relationship)
|
||||||
|
db.commit()
|
||||||
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/relationship-types")
|
||||||
|
def list_relationship_types() -> list[RelationshipTypeInfo]:
|
||||||
|
"""List all available relationship types with their default weights."""
|
||||||
|
return [
|
||||||
|
RelationshipTypeInfo(
|
||||||
|
value=rt.value,
|
||||||
|
display_name=rt.display_name,
|
||||||
|
default_weight=rt.default_weight,
|
||||||
|
)
|
||||||
|
for rt in RelationshipType
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/graph")
|
||||||
|
def get_relationship_graph(db: DbSession) -> GraphData:
|
||||||
|
"""Get all contacts and relationships as graph data for visualization."""
|
||||||
|
contacts = list(db.scalars(select(Contact)).all())
|
||||||
|
relationships = list(db.scalars(select(ContactRelationship)).all())
|
||||||
|
|
||||||
|
nodes = [
|
||||||
|
GraphNode(id=c.id, name=c.name, current_job=c.current_job)
|
||||||
|
for c in contacts
|
||||||
|
]
|
||||||
|
|
||||||
|
edges = [
|
||||||
|
GraphEdge(
|
||||||
|
source=rel.contact_id,
|
||||||
|
target=rel.related_contact_id,
|
||||||
|
relationship_type=rel.relationship_type,
|
||||||
|
closeness_weight=rel.closeness_weight,
|
||||||
|
)
|
||||||
|
for rel in relationships
|
||||||
|
]
|
||||||
|
|
||||||
|
return GraphData(nodes=nodes, edges=edges)
|
||||||
|
|
||||||
|
|
||||||
|
# Serve React frontend
|
||||||
|
if FRONTEND_DIR.exists():
|
||||||
|
app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets")
|
||||||
|
|
||||||
|
@app.get("/{full_path:path}")
|
||||||
|
async def serve_spa(full_path: str) -> FileResponse:
|
||||||
|
"""Serve React SPA for all non-API routes."""
|
||||||
|
file_path = FRONTEND_DIR / full_path
|
||||||
|
if file_path.is_file():
|
||||||
|
return FileResponse(file_path)
|
||||||
|
return FileResponse(FRONTEND_DIR / "index.html")
|
||||||
@@ -3,6 +3,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from python.orm.base import RichieBase, TableBase
|
from python.orm.base import RichieBase, TableBase
|
||||||
from python.orm.temp import Temp
|
from python.orm.contact import (
|
||||||
|
Contact,
|
||||||
|
ContactNeed,
|
||||||
|
ContactRelationship,
|
||||||
|
Need,
|
||||||
|
RelationshipType,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["RichieBase", "TableBase", "Temp"]
|
__all__ = [
|
||||||
|
"Contact",
|
||||||
|
"ContactNeed",
|
||||||
|
"ContactRelationship",
|
||||||
|
"Need",
|
||||||
|
"RelationshipType",
|
||||||
|
"RichieBase",
|
||||||
|
"TableBase",
|
||||||
|
]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from os import getenv
|
from os import getenv
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from sqlalchemy import DateTime, MetaData, create_engine, func
|
from sqlalchemy import DateTime, MetaData, create_engine, func
|
||||||
from sqlalchemy.engine import URL, Engine
|
from sqlalchemy.engine import URL, Engine
|
||||||
@@ -34,11 +35,11 @@ class TableBase(AbstractConcreteBase, RichieBase):
|
|||||||
__abstract__ = True
|
__abstract__ = True
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
server_default=func.now(),
|
server_default=func.now(),
|
||||||
)
|
)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
server_default=func.now(),
|
server_default=func.now(),
|
||||||
onupdate=func.now(),
|
onupdate=func.now(),
|
||||||
@@ -63,7 +64,7 @@ def get_connection_info() -> tuple[str, str, str, str, str | None]:
|
|||||||
f"password{'***' if password else None}\n"
|
f"password{'***' if password else None}\n"
|
||||||
)
|
)
|
||||||
raise ValueError(error)
|
raise ValueError(error)
|
||||||
return database, host, port, username, password
|
return cast("tuple[str, str, str, str, str | None]", (database, host, port, username, password))
|
||||||
|
|
||||||
|
|
||||||
def get_postgres_engine(*, pool_pre_ping: bool = True) -> Engine:
|
def get_postgres_engine(*, pool_pre_ping: bool = True) -> Engine:
|
||||||
|
|||||||
168
python/orm/contact.py
Normal file
168
python/orm/contact.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""Contact database models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from python.orm.base import RichieBase, TableBase
|
||||||
|
|
||||||
|
|
||||||
|
class RelationshipType(str, Enum):
|
||||||
|
"""Relationship types with default closeness weights.
|
||||||
|
|
||||||
|
Default weight is an integer 1-10 where 10 = closest relationship.
|
||||||
|
Users can override this per-relationship in the UI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SPOUSE = "spouse"
|
||||||
|
PARTNER = "partner"
|
||||||
|
PARENT = "parent"
|
||||||
|
CHILD = "child"
|
||||||
|
SIBLING = "sibling"
|
||||||
|
BEST_FRIEND = "best_friend"
|
||||||
|
GRANDPARENT = "grandparent"
|
||||||
|
GRANDCHILD = "grandchild"
|
||||||
|
AUNT_UNCLE = "aunt_uncle"
|
||||||
|
NIECE_NEPHEW = "niece_nephew"
|
||||||
|
COUSIN = "cousin"
|
||||||
|
IN_LAW = "in_law"
|
||||||
|
CLOSE_FRIEND = "close_friend"
|
||||||
|
FRIEND = "friend"
|
||||||
|
MENTOR = "mentor"
|
||||||
|
MENTEE = "mentee"
|
||||||
|
BUSINESS_PARTNER = "business_partner"
|
||||||
|
COLLEAGUE = "colleague"
|
||||||
|
MANAGER = "manager"
|
||||||
|
DIRECT_REPORT = "direct_report"
|
||||||
|
CLIENT = "client"
|
||||||
|
ACQUAINTANCE = "acquaintance"
|
||||||
|
NEIGHBOR = "neighbor"
|
||||||
|
EX = "ex"
|
||||||
|
OTHER = "other"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_weight(self) -> int:
|
||||||
|
"""Return the default closeness weight (1-10) for this relationship type."""
|
||||||
|
weights = {
|
||||||
|
RelationshipType.SPOUSE: 10,
|
||||||
|
RelationshipType.PARTNER: 10,
|
||||||
|
RelationshipType.PARENT: 9,
|
||||||
|
RelationshipType.CHILD: 9,
|
||||||
|
RelationshipType.SIBLING: 9,
|
||||||
|
RelationshipType.BEST_FRIEND: 8,
|
||||||
|
RelationshipType.GRANDPARENT: 7,
|
||||||
|
RelationshipType.GRANDCHILD: 7,
|
||||||
|
RelationshipType.AUNT_UNCLE: 7,
|
||||||
|
RelationshipType.NIECE_NEPHEW: 7,
|
||||||
|
RelationshipType.COUSIN: 7,
|
||||||
|
RelationshipType.IN_LAW: 7,
|
||||||
|
RelationshipType.CLOSE_FRIEND: 6,
|
||||||
|
RelationshipType.FRIEND: 6,
|
||||||
|
RelationshipType.MENTOR: 5,
|
||||||
|
RelationshipType.MENTEE: 5,
|
||||||
|
RelationshipType.BUSINESS_PARTNER: 5,
|
||||||
|
RelationshipType.COLLEAGUE: 4,
|
||||||
|
RelationshipType.MANAGER: 4,
|
||||||
|
RelationshipType.DIRECT_REPORT: 4,
|
||||||
|
RelationshipType.CLIENT: 4,
|
||||||
|
RelationshipType.ACQUAINTANCE: 3,
|
||||||
|
RelationshipType.NEIGHBOR: 3,
|
||||||
|
RelationshipType.EX: 2,
|
||||||
|
RelationshipType.OTHER: 2,
|
||||||
|
}
|
||||||
|
return weights.get(self, 5)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
"""Return a human-readable display name."""
|
||||||
|
return self.value.replace("_", " ").title()
|
||||||
|
|
||||||
|
|
||||||
|
class ContactNeed(RichieBase):
|
||||||
|
"""Association table: Contact <-> Need."""
|
||||||
|
|
||||||
|
__tablename__ = "contact_need"
|
||||||
|
|
||||||
|
contact_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("main.contact.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
need_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("main.need.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactRelationship(RichieBase):
|
||||||
|
"""Association table: Contact <-> Contact with relationship type and weight."""
|
||||||
|
|
||||||
|
__tablename__ = "contact_relationship"
|
||||||
|
|
||||||
|
contact_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("main.contact.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
related_contact_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("main.contact.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
relationship_type: Mapped[str] = mapped_column(String(100))
|
||||||
|
closeness_weight: Mapped[int] = mapped_column(default=5)
|
||||||
|
|
||||||
|
|
||||||
|
class Contact(TableBase):
|
||||||
|
"""Contact model."""
|
||||||
|
|
||||||
|
__tablename__ = "contact"
|
||||||
|
|
||||||
|
name: Mapped[str]
|
||||||
|
|
||||||
|
age: Mapped[int | None]
|
||||||
|
bio: Mapped[str | None]
|
||||||
|
current_job: Mapped[str | None]
|
||||||
|
gender: Mapped[str | None]
|
||||||
|
goals: Mapped[str | None]
|
||||||
|
legal_name: Mapped[str | None]
|
||||||
|
profile_pic: Mapped[str | None]
|
||||||
|
safe_conversation_starters: Mapped[str | None]
|
||||||
|
self_sufficiency_score: Mapped[int | None]
|
||||||
|
social_structure_style: Mapped[str | None]
|
||||||
|
ssn: Mapped[str | None]
|
||||||
|
suffix: Mapped[str | None]
|
||||||
|
timezone: Mapped[str | None]
|
||||||
|
topics_to_avoid: Mapped[str | None]
|
||||||
|
|
||||||
|
needs: Mapped[list[Need]] = relationship(
|
||||||
|
"Need",
|
||||||
|
secondary=ContactNeed.__table__,
|
||||||
|
back_populates="contacts",
|
||||||
|
)
|
||||||
|
|
||||||
|
related_to: Mapped[list[ContactRelationship]] = relationship(
|
||||||
|
"ContactRelationship",
|
||||||
|
foreign_keys=[ContactRelationship.contact_id],
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
related_from: Mapped[list[ContactRelationship]] = relationship(
|
||||||
|
"ContactRelationship",
|
||||||
|
foreign_keys=[ContactRelationship.related_contact_id],
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Need(TableBase):
|
||||||
|
"""Need/accommodation model (e.g., light sensitive, ADHD)."""
|
||||||
|
|
||||||
|
__tablename__ = "need"
|
||||||
|
|
||||||
|
name: Mapped[str]
|
||||||
|
description: Mapped[str | None]
|
||||||
|
|
||||||
|
contacts: Mapped[list[Contact]] = relationship(
|
||||||
|
"Contact",
|
||||||
|
secondary=ContactNeed.__table__,
|
||||||
|
back_populates="needs",
|
||||||
|
)
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"""Temporary ORM model."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from sqlalchemy import String
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from python.orm.base import TableBase
|
|
||||||
|
|
||||||
|
|
||||||
class Temp(TableBase):
|
|
||||||
"""Temporary table for initial testing."""
|
|
||||||
|
|
||||||
__tablename__ = "temp"
|
|
||||||
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
Reference in New Issue
Block a user