From 2b6d923ae1120bd93afc4489adbda00cd90b55bb Mon Sep 17 00:00:00 2001 From: luxferre Date: Mon, 25 May 2026 15:15:50 +0100 Subject: [PATCH] feat: contact model restructure Blank contacts are now generated on org creation and assigned to each contact type. These contacts are linked to the org, only accessible to the org, and removed when the org is removed. With this all contact endpoints have been removed. Contact manipulation is done via the org only. --- .../2026-05-25_contact_model_changes.py | 34 ++++++ src/contact/models.py | 4 +- src/contact/router.py | 104 +--------------- src/contact/schemas.py | 14 ++- src/organisation/models.py | 4 + src/organisation/router.py | 111 ++++++++++-------- src/organisation/schemas.py | 40 ++++--- 7 files changed, 146 insertions(+), 165 deletions(-) create mode 100644 .alembic/versions/2026-05-25_contact_model_changes.py diff --git a/.alembic/versions/2026-05-25_contact_model_changes.py b/.alembic/versions/2026-05-25_contact_model_changes.py new file mode 100644 index 0000000..8ad1f5a --- /dev/null +++ b/.alembic/versions/2026-05-25_contact_model_changes.py @@ -0,0 +1,34 @@ +"""Contact model changes + +Revision ID: 8132c4b88665 +Revises: a147965e644e +Create Date: 2026-05-25 13:09:22.635058 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8132c4b88665' +down_revision: Union[str, Sequence[str], None] = 'a147965e644e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('contact', sa.Column('org_id', sa.Integer(), nullable=False)) + op.create_foreign_key(None, 'contact', 'organisation', ['org_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'contact', type_='foreignkey') + op.drop_column('contact', 'org_id') + # ### end Alembic commands ### diff --git a/src/contact/models.py b/src/contact/models.py index 4f94601..e3d0d05 100644 --- a/src/contact/models.py +++ b/src/contact/models.py @@ -5,7 +5,7 @@ Models: - Contact: id[pk], email, first_name, last_name, phonenumber, vat_number street_address, post_office_box_number, address_locality, country_code, address_region, postal_code """ -from sqlalchemy import Column, Integer, String +from sqlalchemy import Column, Integer, String, ForeignKey from src.database import Base @@ -27,3 +27,5 @@ class Contact(Base): country_code = Column(String) # Eg GB address_region = Column(String, default=None, nullable=True) postal_code = Column(String) + + org_id = Column(Integer, ForeignKey("organisation.id", ondelete="CASCADE"), nullable=False) diff --git a/src/contact/router.py b/src/contact/router.py index 3ed9a0f..9528fd9 100644 --- a/src/contact/router.py +++ b/src/contact/router.py @@ -9,110 +9,10 @@ Endpoints: - [patch]/{contact_id} - Updates the details of an existing contact - [delete]/{contact_id} - Deletes a contact by ID """ -from typing import Annotated +from fastapi import APIRouter -from fastapi import APIRouter, HTTPException -from fastapi.params import Path - -from sqlalchemy import or_ - -from src.contact.schemas import ContactContactGetResponse, ContactAddressGetResponse, ContactContactPostRequest, \ - ContactUpdateRequest, ContactOrgGetResponse -from src.contact.models import Contact - -from src.database import db_dependency -from src.organisation.models import Organisation as Org -from src.organisation.constants import ContactType router = APIRouter( prefix="/contact", tags=["contact"], -) - - -@router.get("/{contact_id}", response_model=ContactContactGetResponse) -async def get_contact_details_by_id(contact_id: int, db: db_dependency): - contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) - if contact_model is None: - raise HTTPException(status_code=404, detail="Contact not found") - - return contact_model - - -@router.get("/{contact_id}/address", response_model=ContactAddressGetResponse) -async def get_contact_address_by_id(contact_id: int, db: db_dependency): - contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) - if contact_model is None: - raise HTTPException(status_code=404, detail="Contact not found") - - return contact_model - - -@router.post("/") -async def create_contact(db: db_dependency, contact_request: ContactContactPostRequest): - contact_model = Contact(**contact_request.model_dump()) - - db.add(contact_model) - db.commit() - - -@router.patch("/{contact_id}") -async def update_contact(db: db_dependency, contact_request: ContactUpdateRequest, contact_id: Annotated[int, Path(gt=0)]): - contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) - if contact_model is None: - raise HTTPException(status_code=404, detail="Contact not found") - - update_data = contact_request.model_dump(exclude_none=True) - for key, value in update_data.items(): - if hasattr(contact_model, key): - setattr(contact_model, key, value) - else: - raise HTTPException(status_code=422, detail="Invalid keys in update request") - - db.add(contact_model) - db.commit() - - -@router.delete("/{contact_id}") -async def delete_contact(db: db_dependency, contact_id: Annotated[int, Path(gt=0)]): - contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) - if contact_model is None: - raise HTTPException(status_code=404, detail="Contact not found") - - db.delete(contact_model) - db.commit() - - -@router.get("/{contact_id}/orgs", response_model=list[ContactOrgGetResponse]) -async def get_contact_orgs(db: db_dependency, contact_id: Annotated[int, Path(gt=0)]): - contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) - if contact_model is None: - raise HTTPException(status_code=404, detail="Contact not found") - - org_models = (db.query(Org).filter( - or_( - Org.owner_contact_id == contact_id, - Org.billing_contact_id == contact_id, - Org.security_contact_id == contact_id - ) - ).all()) - - response = [] - - for org in org_models: - types=[] - if org.owner_contact_id == contact_id: - types.append(ContactType.OWNER) - if org.billing_contact_id == contact_id: - types.append(ContactType.BILLING) - if org.security_contact_id == contact_id: - types.append(ContactType.SECURITY) - - org_response_model = ContactOrgGetResponse( - name=str(org.name), - contact_types=types, - ) - response.append(org_response_model) - - - return response +) \ No newline at end of file diff --git a/src/contact/schemas.py b/src/contact/schemas.py index d64e265..c3d705c 100644 --- a/src/contact/schemas.py +++ b/src/contact/schemas.py @@ -7,12 +7,24 @@ Models: """ from typing import Optional -from pydantic import EmailStr +from pydantic import EmailStr, ConfigDict from src.organisation.constants import ContactType from src.schemas import CustomBaseModel +class ContactAddress(CustomBaseModel): + model_config = ConfigDict(from_attributes=True, extra="ignore") + + post_office_box_number: Optional[str] = None + street_address: Optional[str] = None + street_address_line_2: Optional[str] = None + locality: Optional[str] = None + address_region: Optional[str] = None + country_code: Optional[str] = None + postal_code: Optional[str] = None + + class ContactContactGetResponse(CustomBaseModel): email: str first_name: str diff --git a/src/organisation/models.py b/src/organisation/models.py index 58be79f..f696824 100644 --- a/src/organisation/models.py +++ b/src/organisation/models.py @@ -39,6 +39,10 @@ class Organisation(Base): def root_user_email(self): return self.root_user_rel.email if self.root_user_rel else None + billing_contact_rel = relationship("Contact", foreign_keys=[billing_contact_id]) + security_contact_rel = relationship("Contact", foreign_keys=[security_contact_id]) + owner_contact_rel = relationship("Contact", foreign_keys=[owner_contact_id]) + class OrgUsers(Base): __tablename__ = "orgusers" diff --git a/src/organisation/router.py b/src/organisation/router.py index 189da5f..8d6b692 100644 --- a/src/organisation/router.py +++ b/src/organisation/router.py @@ -19,6 +19,7 @@ from fastapi.params import Path, Query from sqlalchemy.sql import exists +from src.contact.schemas import ContactAddress from src.database import db_dependency from src.contact.models import Contact from src.iam.models import Group @@ -42,9 +43,9 @@ async def get_org_by_id(db: db_dependency, org_model: org_model_dependency, org_ response = { "name": org_model.name, "status": org_model.status, - "owner_contact": (db.query(Contact).filter(Contact.id == org_model.owner_contact_id).first()), - "billing_contact": (db.query(Contact).filter(Contact.id == org_model.billing_contact_id).first()), - "security_contact": (db.query(Contact).filter(Contact.id == org_model.security_contact_id).first()), + "owner_contact": org_model.owner_contact_rel.email, + "billing_contact": org_model.billing_contact_rel.email, + "security_contact": org_model.security_contact_rel.email, "root_user": org_model.root_user_email } @@ -54,11 +55,17 @@ async def get_org_by_id(db: db_dependency, org_model: org_model_dependency, org_ @router.post("/") async def create_org(db: db_dependency, org_request: OrgOrgPostRequest): # TODO: Root user from current user - org_model = Org(**org_request.model_dump()) + org_model = Org(name=org_request.name, intake_questionnaire=org_request.intake_questionnaire) org_model.status = "partial" # Status is always set to partial at first, see update_questionnaire() doc db.add(org_model) + db.flush() + for contact_type in ["billing_contact_id", "security_contact_id", "owner_contact_id"]: + contact_model = Contact(org_id=org_model.id) + db.add(contact_model) + db.flush() + org_model.__setattr__(contact_type, contact_model.id) db.commit() @@ -95,26 +102,6 @@ async def update_status(db: db_dependency, status_request: OrgStatusPatchRequest db.commit() -@router.patch("/{org_id}/contact") -async def update_contact(db: db_dependency, contact_request: OrgContactPatchRequest, org_id: Annotated[int, Path(gt=0)]): - org_model = db.query(Org).filter(Org.id == org_id).first() - if org_model is None: - raise HTTPException(status_code=404, detail="Organisation not found") - - match contact_request.contact_type: - case "billing": - org_model.billing_contact_id = contact_request.contact_id - case "security": - org_model.security_contact_id = contact_request.contact_id - case "owner": - org_model.owner_contact_id = contact_request.contact_id - case _: - raise HTTPException(status_code=422, detail="Invalid contact type") - - db.add(org_model) - db.commit() - - @router.get("/{org_id}/users", response_model=list[OrgUserGetResponse]) async def get_users(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): org_exists = db.query(exists().where(Org.id == org_id)).scalar() @@ -147,29 +134,6 @@ async def delete_organisation_by_id(db: db_dependency, org_id: Annotated[int, Pa db.commit() -@router.get("/{org_id}/contact/{contact_type}", response_model=OrgContactGetResponse) -async def get_contact(db: db_dependency, contact_type: ContactType, org_id: Annotated[int, Path(gt=0)]): - org_model = db.query(Org).filter(Org.id == org_id).first() - if org_model is None: - raise HTTPException(status_code=404, detail="Organisation not found") - - match contact_type: - case "billing": - contact_id = org_model.billing_contact_id - case "security": - contact_id = org_model.security_contact_id - case "owner": - contact_id = org_model.owner_contact_id - case _: - raise HTTPException(status_code=422, detail="Invalid contact type") - - contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) - if contact_model is None: - raise HTTPException(status_code=404, detail="Contact not found") - - return contact_model - - @router.patch("/{org_id}/root_user") async def update_root_user(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)], root_user: Annotated[int, Query(gt=0)]): # TODO: Request model, ditch query @@ -197,3 +161,56 @@ async def remove_user_from_org(db: db_dependency, org_model: org_model_dependenc db.delete(orguser_model) db.commit() pass + + +@router.get("/{org_id}/contact", response_model=OrgContactGetResponse) +async def get_contact(db: db_dependency, org_model: org_model_dependency, contact_type: Annotated[ContactType, Query()], org_id: Annotated[int, Path(gt=0)]): + match contact_type: + case "billing": + contact_id = org_model.billing_contact_id + case "security": + contact_id = org_model.security_contact_id + case "owner": + contact_id = org_model.owner_contact_id + case _: + raise HTTPException(status_code=422, detail="Invalid contact type") + + contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) + if contact_model is None: + raise HTTPException(status_code=404, detail="Contact not found") + + address = ContactAddress.model_validate(contact_model) + + response = OrgContactGetResponse.model_construct( + **contact_model.__dict__, + address=address + ) + + return response + + + +@router.patch("/{org_id}/contact") +async def update_contact(db: db_dependency, org_model: org_model_dependency, contact_type: Annotated[ContactType, Query()], contact_request: OrgContactPatchRequest, org_id: Annotated[int, Path(gt=0)]): + match contact_type: + case "billing": + contact_id = org_model.billing_contact_id + case "security": + contact_id = org_model.security_contact_id + case "owner": + contact_id = org_model.owner_contact_id + case _: + raise HTTPException(status_code=422, detail="Invalid contact type") + + contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) + if contact_model is None: + raise HTTPException(status_code=404, detail="Contact not found") + + update_data = contact_request.model_dump(exclude_none=True) + for key, value in update_data.items(): + if hasattr(contact_model, key): + setattr(contact_model, key, value) + else: + raise HTTPException(status_code=422, detail="Invalid keys in update request") + db.add(org_model) + db.commit() \ No newline at end of file diff --git a/src/organisation/schemas.py b/src/organisation/schemas.py index 86fe8f0..13c9689 100644 --- a/src/organisation/schemas.py +++ b/src/organisation/schemas.py @@ -7,9 +7,11 @@ Models: """ from typing import Optional +from pydantic import EmailStr, ConfigDict + from src.schemas import CustomBaseModel from src.organisation.constants import Status, ContactType - +from src.contact.schemas import ContactAddress class OrgQuestionnaire(CustomBaseModel): question_one: str @@ -21,10 +23,6 @@ class OrgOrgPostRequest(CustomBaseModel): name: str intake_questionnaire: Optional[OrgQuestionnaire] = None - billing_contact_id: Optional[int] = None - security_contact_id: Optional[int] = None - owner_contact_id: Optional[int] = None - class OrgQuestionnairePatchRequest(CustomBaseModel): intake_questionnaire: OrgQuestionnaire partial: bool @@ -33,8 +31,18 @@ class OrgStatusPatchRequest(CustomBaseModel): status: Status class OrgContactPatchRequest(CustomBaseModel): - contact_id: int - contact_type: ContactType + email: Optional[EmailStr] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + phonenumber: Optional[str] = None + vat_number: Optional[str] = None + post_office_box_number: Optional[str] = None + street_address: Optional[str] = None + street_address_line_2: Optional[str] = None + locality: Optional[str] = None + address_region: Optional[str] = None + country_code: Optional[str] = None + postal_code: Optional[str] = None class OrgUserPostRequest(CustomBaseModel): user_id: int @@ -43,16 +51,20 @@ class OrgUserGetResponse(CustomBaseModel): user_id: int class OrgContactGetResponse(CustomBaseModel): - email: str - first_name: str - last_name: str - phonenumber: str + model_config = ConfigDict(from_attributes=True, extra="ignore") + + email: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + phonenumber: Optional[str] = None vat_number: Optional[str] = None + address: ContactAddress + class OrgOrgGetResponse(CustomBaseModel): name: str status: Status root_user: Optional[str] = None - owner_contact: Optional[OrgContactGetResponse] = None - billing_contact: Optional[OrgContactGetResponse] = None - security_contact: Optional[OrgContactGetResponse] = None + owner_contact: Optional[str] = None + billing_contact: Optional[str] = None + security_contact: Optional[str] = None