Skip to content

Feat/multi address - #8

Open
nex-code-c wants to merge 4 commits into
David-Parry:trunkfrom
nex-code-c:feat/multi-address
Open

Feat/multi address#8
nex-code-c wants to merge 4 commits into
David-Parry:trunkfrom
nex-code-c:feat/multi-address

Conversation

@nex-code-c

Copy link
Copy Markdown

No description provided.

Optional `photo` on the contact, stored as a base64 data URL so it lives on
the record with no file storage.

On ContactBase, so create, replace, and read all carry it, and on
ContactUpdate so PATCH can set or clear it. Tests pin the round trip and the
PUT contract: a full replace that omits `photo` clears it, which is what the
edit form has to carry through.
Addresses the review on David-Parry#6 and its frontend counterpart.

The API accepted any string as a photo and echoed it back into an <img src>.
It now takes only a base64 data URL for a PNG, JPEG, GIF, or WebP, capped at
roughly 2 MB decoded, on both POST/PUT and PATCH.

The web client rejects the wrong file type or an oversized file with a message
naming the reason, and shrinks what it does accept to a 512px JPEG before
submitting. That keeps a stored photo in the tens of KB, so photos no longer
bloat every list response or approach the 1MB server action body limit — which
is raised to 4mb anyway, as headroom for browsers that cannot repaint the
image.

Also fixes a race the review caught: a slow read of an earlier pick could
land after a newer one and win. Reads now carry a sequence number and only
the newest is allowed to apply, and removing a photo outranks any read still
in flight. Image decoding is bounded by a timeout, and a canvas-less
environment keeps the original data URL rather than losing the photo.

README notes that create_all does not alter existing tables, so a file
database made by an older build will not gain the photo column on its own.
A contact had exactly one address spread across five columns. Those are gone,
replaced by an `addresses` table: its own primary key, a foreign key back to
`contacts` with ON DELETE CASCADE, and a `type` constrained to Home/Work/Other.
A contact can now hold as many as it needs, and a fourth address is a row
rather than a schema change.

Addresses come back nested under the contact everywhere it is returned, and
there are two ways to write them: the contact's own POST/PUT take an
`addresses` array and replace the set in one request, while
/contacts/{id}/addresses gives per-address create, read, replace, and delete.
PATCH leaves addresses untouched unless the key is sent.

The relationship uses delete-orphan so the ORM cleans up children on both
SQLite and Postgres, not just where the FK constraint is enforced.
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add multi-address contacts and validated inline photos

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Model multiple typed addresses per contact with nested writes and dedicated CRUD routes.
• Add validated base64 contact photos with explicit PUT and PATCH semantics.
• Cover address ownership, cascading deletion, photo limits, and OpenAPI contracts.
Diagram

graph TD
  Client["API Client"] --> Contacts["Contact Routes"] --> Schemas["Schemas"] --> Crud["CRUD Layer"]
  Client --> Addresses["Address Routes"] --> Schemas
  Crud --> ContactDB[("Contacts Table")]
  Crud --> AddressDB[("Addresses Table")]
  ContactDB -->|"one-to-many"| AddressDB
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Store addresses as contact JSON
  • ➕ Simplifies nested contact writes
  • ➕ Avoids a separate address table and router
  • ➖ Prevents efficient per-address CRUD and querying
  • ➖ Weakens relational integrity and ownership enforcement
2. Store photos in blob storage
  • ➕ Keeps list responses and database rows small
  • ➕ Supports caching and independent media delivery
  • ➖ Adds storage infrastructure and file lifecycle management
  • ➖ Conflicts with the service's self-contained deployment model

Recommendation: Keep the normalized address table and nested ownership routes; they best support unlimited addresses, referential integrity, and individual operations. Inline validated photos are reasonable for this self-contained service with the size cap, but blob storage should replace them if production scale or large contact lists become priorities.

Files changed (10) +633 / -60

Enhancement (5) +418 / -44
crud.pyPersist nested address sets and individual address operations +57/-5

Persist nested address sets and individual address operations

• Separates address payloads from contact columns and replaces address collections during contact create or full replace. PATCH only replaces addresses when supplied, while new helpers implement ownership-scoped address CRUD.

app/crud.py

main.pyRegister the address API router and OpenAPI tag +9/-1

Register the address API router and OpenAPI tag

• Adds address API metadata and includes the new nested address router in the FastAPI application.

app/main.py

models.pyNormalize addresses and add inline contact photos +67/-8

Normalize addresses and add inline contact photos

• Replaces scalar contact address columns with a typed 'Address' entity and cascading one-to-many relationship. Adds the optional contact photo column and address timestamps, ownership, and ordering.

app/models.py

addresses.pyAdd ownership-scoped nested address endpoints +140/-0

Add ownership-scoped nested address endpoints

• Introduces list, create, read, replace, and delete routes beneath each contact. Address lookups enforce contact ownership and return indistinguishable 404 responses for missing or foreign records.

app/routers/addresses.py

schemas.pyDefine address contracts and validate photo data URLs +145/-30

Define address contracts and validate photo data URLs

• Adds typed address create, replace, and read schemas and nests them into contact contracts with distinct PUT and PATCH behavior. Restricts photos to supported base64 image data URLs under the configured size ceiling.

app/schemas.py

Tests (3) +156 / -5
conftest.pyRemove obsolete scalar address fixture fields +0/-4

Remove obsolete scalar address fixture fields

• Updates the shared contact payload to match the normalized address contract.

tests/conftest.py

test_contacts_api.pyTest photo contracts and multi-address lifecycle +150/-0

Test photo contracts and multi-address lifecycle

• Adds coverage for photo round trips, validation, size limits, clearing, and PATCH preservation. Tests nested address writes, replacement semantics, dedicated CRUD routes, ownership isolation, response nesting, and cascading deletion.

tests/test_contacts_api.py

test_openapi.pyPin address API metadata and operation identifiers +6/-1

Pin address API metadata and operation identifiers

• Extends OpenAPI assertions to require the address tag and all five stable nested-address operation IDs.

tests/test_openapi.py

Documentation (1) +48 / -2
README.mdDocument photos, multi-address APIs, and migration limitations +48/-2

Document photos, multi-address APIs, and migration limitations

• Documents the new nested address endpoints, normalized address model, replacement semantics, photo format and limits, and cascading deletion. It also warns that 'create_all' does not migrate existing persistent databases.

README.md

Other (1) +11 / -9
seed.pySeed contacts with typed address records +11/-9

Seed contacts with typed address records

• Converts sample contacts from scalar location fields to nested Home, Work, and Other address payloads.

app/seed.py

@qodo-code-review

qodo-code-review Bot commented Aug 27, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Existing databases cannot upgrade 🐞 Bug ☼ Reliability
Description
Adding the mapped Contact.photo column without a schema migration leaves older persistent
contacts tables unchanged, so contact inserts or ORM reads fail on the missing column.
create_all() only creates missing tables and cannot make this upgrade safe.
Code

app/models.py[39]

+    photo: Mapped[str | None] = mapped_column(Text)
Evidence
The PR maps photo on every Contact, while all contact reads use that ORM entity. Startup still
performs only Base.metadata.create_all, and the test fixture recreates tables from scratch, so
tests cannot exercise an existing-schema upgrade.

app/models.py[37-39]
app/crud.py[20-21]
app/crud.py[43-64]
app/database.py[48-52]
tests/conftest.py[15-19]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Persistent databases created before this PR do not receive the new `contacts.photo` column, causing inserts and contact queries to fail after upgrade.

## Issue Context
Startup only invokes SQLAlchemy `create_all`, which does not alter existing tables. Add a real migration mechanism and a migration that adds the nullable photo column before the new model is used.

## Fix Focus Areas
- app/models.py[37-39]
- app/database.py[48-52]
- app/main.py[67-70]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Existing addresses become invisible 🐞 Bug ≡ Correctness
Description
The PR removes the legacy scalar address mapping and reads only the new addresses relationship,
but no migration copies existing address/city/state/postal_code/country values into
address rows. After an upgrade, previously stored addresses disappear from every API response even
if the schema is manually made queryable.
Code

app/models.py[L26-29]

-    address: Mapped[str | None] = mapped_column(String(300))
-    city: Mapped[str | None] = mapped_column(String(120))
-    state: Mapped[str | None] = mapped_column(String(120))
-    postal_code: Mapped[str | None] = mapped_column(String(20))
Evidence
The removed diff lines establish that addresses were previously stored directly on contacts. The new
branch serializes addresses exclusively from the relationship backed by the new table, while
initialization has no data-migration step, so no rows are created from legacy values.

app/models.py[52-57]
app/models.py[67-106]
app/schemas.py[250-272]
app/database.py[48-52]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Existing contacts store address data in scalar columns, but the new API reads only rows from the `addresses` table, making legacy address data invisible.

## Issue Context
Add a data migration that creates an address row for each contact with legacy address content before retiring or ignoring the old columns. Preserve all five legacy fields and choose/document the migrated address type.

## Fix Focus Areas
- app/models.py[26-30]
- app/models.py[52-57]
- app/models.py[67-106]
- app/database.py[48-52]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Invalid photos pass validation 🐞 Bug ≡ Correctness
Description
validate_photo checks only the encoded string's length and alphabet, so values such as
data:image/png;base64,AAAA are accepted despite not being PNG images, and malformed Base64 padding
can also pass. Because it never decodes the payload, the 2,800,000-character threshold additionally
permits about 2,099,982 decoded bytes, exceeding the documented 2 MB ceiling.
Code

app/schemas.py[R88-90]

+    if len(value) > MAX_PHOTO_LENGTH:
+        raise ValueError("photo must be 2 MB or smaller once decoded")
+    if not _PHOTO_DATA_URL.match(value):
Evidence
The validator returns after a regex match and never decodes or inspects bytes; the model stores the
string verbatim and the API contract describes it as a supported image no larger than 2 MB once
decoded. Both create/replace/read and PATCH attach this validator, so the weak check governs every
write path.

app/schemas.py[73-95]
app/schemas.py[151-161]
app/schemas.py[235-247]
app/models.py[37-39]
README.md[125-129]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The photo validator accepts non-images and malformed Base64, and applies the size limit to encoded text rather than decoded bytes.

## Issue Context
Strictly decode Base64 with validation, reject decoding errors, check the decoded byte length against the chosen 2 MB definition, and verify that the bytes match the declared PNG/JPEG/GIF/WebP format. Add negative tests for valid Base64 containing non-image bytes, invalid padding, mismatched signatures, and the exact decoded boundary.

## Fix Focus Areas
- app/schemas.py[73-95]
- tests/test_contacts_api.py[168-181]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
Review mode: ⚖️ Balanced

Grey Divider

Tip of the day
💡 Did you know, you can ask Qodo to dismiss a finding you disagree with, with your reason on record

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit 548a3ce ⚖️ Balanced

Results up to commit dbdcb9b ⚖️ Balanced


🐞 Bugs (3) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Action required
1. Existing databases cannot upgrade 🐞 Bug ☼ Reliability
Description
Adding the mapped Contact.photo column without a schema migration leaves older persistent
contacts tables unchanged, so contact inserts or ORM reads fail on the missing column.
create_all() only creates missing tables and cannot make this upgrade safe.
Code

app/models.py[39]

+    photo: Mapped[str | None] = mapped_column(Text)
Evidence
The PR maps photo on every Contact, while all contact reads use that ORM entity. Startup still
performs only Base.metadata.create_all, and the test fixture recreates tables from scratch, so
tests cannot exercise an existing-schema upgrade.

app/models.py[37-39]
app/crud.py[20-21]
app/crud.py[43-64]
app/database.py[48-52]
tests/conftest.py[15-19]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Persistent databases created before this PR do not receive the new `contacts.photo` column, causing inserts and contact queries to fail after upgrade.

## Issue Context
Startup only invokes SQLAlchemy `create_all`, which does not alter existing tables. Add a real migration mechanism and a migration that adds the nullable photo column before the new model is used.

## Fix Focus Areas
- app/models.py[37-39]
- app/database.py[48-52]
- app/main.py[67-70]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Existing addresses become invisible 🐞 Bug ≡ Correctness
Description
The PR removes the legacy scalar address mapping and reads only the new addresses relationship,
but no migration copies existing address/city/state/postal_code/country values into
address rows. After an upgrade, previously stored addresses disappear from every API response even
if the schema is manually made queryable.
Code

app/models.py[L26-29]

-    address: Mapped[str | None] = mapped_column(String(300))
-    city: Mapped[str | None] = mapped_column(String(120))
-    state: Mapped[str | None] = mapped_column(String(120))
-    postal_code: Mapped[str | None] = mapped_column(String(20))
Evidence
The removed diff lines establish that addresses were previously stored directly on contacts. The new
branch serializes addresses exclusively from the relationship backed by the new table, while
initialization has no data-migration step, so no rows are created from legacy values.

app/models.py[52-57]
app/models.py[67-106]
app/schemas.py[250-272]
app/database.py[48-52]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Existing contacts store address data in scalar columns, but the new API reads only rows from the `addresses` table, making legacy address data invisible.

## Issue Context
Add a data migration that creates an address row for each contact with legacy address content before retiring or ignoring the old columns. Preserve all five legacy fields and choose/document the migrated address type.

## Fix Focus Areas
- app/models.py[26-30]
- app/models.py[52-57]
- app/models.py[67-106]
- app/database.py[48-52]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
3. Invalid photos pass validation 🐞 Bug ≡ Correctness
Description
validate_photo checks only the encoded string's length and alphabet, so values such as
data:image/png;base64,AAAA are accepted despite not being PNG images, and malformed Base64 padding
can also pass. Because it never decodes the payload, the 2,800,000-character threshold additionally
permits about 2,099,982 decoded bytes, exceeding the documented 2 MB ceiling.
Code

app/schemas.py[R88-90]

+    if len(value) > MAX_PHOTO_LENGTH:
+        raise ValueError("photo must be 2 MB or smaller once decoded")
+    if not _PHOTO_DATA_URL.match(value):
Evidence
The validator returns after a regex match and never decodes or inspects bytes; the model stores the
string verbatim and the API contract describes it as a supported image no larger than 2 MB once
decoded. Both create/replace/read and PATCH attach this validator, so the weak check governs every
write path.

app/schemas.py[73-95]
app/schemas.py[151-161]
app/schemas.py[235-247]
app/models.py[37-39]
README.md[125-129]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The photo validator accepts non-images and malformed Base64, and applies the size limit to encoded text rather than decoded bytes.

## Issue Context
Strictly decode Base64 with validation, reject decoding errors, check the decoded byte length against the chosen 2 MB definition, and verify that the bytes match the declared PNG/JPEG/GIF/WebP format. Add negative tests for valid Base64 containing non-image bytes, invalid padding, mismatched signatures, and the exact decoded boundary.

## Fix Focus Areas
- app/schemas.py[73-95]
- tests/test_contacts_api.py[168-181]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Results up to commit 548a3ce ⚖️ Balanced


No changes from previous review

Grey Divider

Qodo Logo

Comment thread app/models.py

# Base64 data URL ("data:image/png;base64,..."), so a photo needs no blob
# store or static file route -- it travels with the contact JSON.
photo: Mapped[str | None] = mapped_column(Text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Existing databases cannot upgrade 🐞 Bug ☼ Reliability

Adding the mapped Contact.photo column without a schema migration leaves older persistent
contacts tables unchanged, so contact inserts or ORM reads fail on the missing column.
create_all() only creates missing tables and cannot make this upgrade safe.
Agent Prompt
## Issue description
Persistent databases created before this PR do not receive the new `contacts.photo` column, causing inserts and contact queries to fail after upgrade.

## Issue Context
Startup only invokes SQLAlchemy `create_all`, which does not alter existing tables. Add a real migration mechanism and a migration that adds the nullable photo column before the new model is used.

## Fix Focus Areas
- app/models.py[37-39]
- app/database.py[48-52]
- app/main.py[67-70]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread app/models.py
Comment on lines -26 to -29
address: Mapped[str | None] = mapped_column(String(300))
city: Mapped[str | None] = mapped_column(String(120))
state: Mapped[str | None] = mapped_column(String(120))
postal_code: Mapped[str | None] = mapped_column(String(20))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Existing addresses become invisible 🐞 Bug ≡ Correctness

The PR removes the legacy scalar address mapping and reads only the new addresses relationship,
but no migration copies existing address/city/state/postal_code/country values into
address rows. After an upgrade, previously stored addresses disappear from every API response even
if the schema is manually made queryable.
Agent Prompt
## Issue description
Existing contacts store address data in scalar columns, but the new API reads only rows from the `addresses` table, making legacy address data invisible.

## Issue Context
Add a data migration that creates an address row for each contact with legacy address content before retiring or ignoring the old columns. Preserve all five legacy fields and choose/document the migrated address type.

## Fix Focus Areas
- app/models.py[26-30]
- app/models.py[52-57]
- app/models.py[67-106]
- app/database.py[48-52]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread app/schemas.py Outdated
Comment on lines +88 to +90
if len(value) > MAX_PHOTO_LENGTH:
raise ValueError("photo must be 2 MB or smaller once decoded")
if not _PHOTO_DATA_URL.match(value):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Invalid photos pass validation 🐞 Bug ≡ Correctness

validate_photo checks only the encoded string's length and alphabet, so values such as
data:image/png;base64,AAAA are accepted despite not being PNG images, and malformed Base64 padding
can also pass. Because it never decodes the payload, the 2,800,000-character threshold additionally
permits about 2,099,982 decoded bytes, exceeding the documented 2 MB ceiling.
Agent Prompt
## Issue description
The photo validator accepts non-images and malformed Base64, and applies the size limit to encoded text rather than decoded bytes.

## Issue Context
Strictly decode Base64 with validation, reject decoding errors, check the decoded byte length against the chosen 2 MB definition, and verify that the bytes match the declared PNG/JPEG/GIF/WebP format. Add negative tests for valid Base64 containing non-image bytes, invalid padding, mismatched signatures, and the exact decoded boundary.

## Fix Focus Areas
- app/schemas.py[73-95]
- tests/test_contacts_api.py[168-181]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Qodo review follow-up on the photo PR.

validate_photo only checked that the payload used the base64 alphabet, so
strings that cannot decode ("data:image/png;base64,A") were stored and
handed back to clients as an image. It also measured the size ceiling on
the encoded characters, which let a valid payload decode to ~2.1 MB while
the field documents 2 MB. Decode once, and check the decoded length.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nex-code-c

Copy link
Copy Markdown
Author

/review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 548a3ce

@nex-code-c

Copy link
Copy Markdown
Author

/review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 548a3ce

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant