From 8afa4fce6c3e15405cf8262ff77537e04eb5264d Mon Sep 17 00:00:00 2001 From: Richie Cahill Date: Sun, 11 Jan 2026 18:37:40 -0500 Subject: [PATCH] added Contact api and data model --- .../versions/2026_01_10-base_7bd2bdc231dc.py | 49 -- ..._01_11-created_contact_api_edd7dd61a3d2.py | 113 ++++ python/api/__init__.py | 1 + python/api/contact_api.py | 536 ++++++++++++++++++ python/orm/__init__.py | 18 +- python/orm/base.py | 7 +- python/orm/contact.py | 168 ++++++ python/orm/temp.py | 16 - 8 files changed, 838 insertions(+), 70 deletions(-) delete mode 100644 python/alembic/versions/2026_01_10-base_7bd2bdc231dc.py create mode 100644 python/alembic/versions/2026_01_11-created_contact_api_edd7dd61a3d2.py create mode 100644 python/api/__init__.py create mode 100644 python/api/contact_api.py create mode 100644 python/orm/contact.py delete mode 100644 python/orm/temp.py diff --git a/python/alembic/versions/2026_01_10-base_7bd2bdc231dc.py b/python/alembic/versions/2026_01_10-base_7bd2bdc231dc.py deleted file mode 100644 index 2d3bac3..0000000 --- a/python/alembic/versions/2026_01_10-base_7bd2bdc231dc.py +++ /dev/null @@ -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 ### diff --git a/python/alembic/versions/2026_01_11-created_contact_api_edd7dd61a3d2.py b/python/alembic/versions/2026_01_11-created_contact_api_edd7dd61a3d2.py new file mode 100644 index 0000000..55c98b8 --- /dev/null +++ b/python/alembic/versions/2026_01_11-created_contact_api_edd7dd61a3d2.py @@ -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 ### diff --git a/python/api/__init__.py b/python/api/__init__.py new file mode 100644 index 0000000..7b09757 --- /dev/null +++ b/python/api/__init__.py @@ -0,0 +1 @@ +"""FastAPI applications.""" diff --git a/python/api/contact_api.py b/python/api/contact_api.py new file mode 100644 index 0000000..fe2696d --- /dev/null +++ b/python/api/contact_api.py @@ -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") diff --git a/python/orm/__init__.py b/python/orm/__init__.py index 053f98b..3a40187 100644 --- a/python/orm/__init__.py +++ b/python/orm/__init__.py @@ -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", +] diff --git a/python/orm/base.py b/python/orm/base.py index 0f49a57..935bdf5 100644 --- a/python/orm/base.py +++ b/python/orm/base.py @@ -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: diff --git a/python/orm/contact.py b/python/orm/contact.py new file mode 100644 index 0000000..27cb64d --- /dev/null +++ b/python/orm/contact.py @@ -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", + ) diff --git a/python/orm/temp.py b/python/orm/temp.py deleted file mode 100644 index 9db674e..0000000 --- a/python/orm/temp.py +++ /dev/null @@ -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)