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 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 os import getenv
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import DateTime, MetaData, create_engine, func
|
||||
from sqlalchemy.engine import URL, Engine
|
||||
@@ -34,11 +35,11 @@ class TableBase(AbstractConcreteBase, RichieBase):
|
||||
__abstract__ = True
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
created: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
updated: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=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"
|
||||
)
|
||||
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:
|
||||
|
||||
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