Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
ec8bb03
Add upsert on external_id to _get_bulk_updates()
kaapstorm Nov 7, 2025
34a70e7
Tests
kaapstorm Nov 7, 2025
c7e8be2
Documentation
kaapstorm Nov 7, 2025
77c8fec
Move upsert logic into `_get_individual_update()`
kaapstorm Nov 10, 2025
b5c6837
Update tests
kaapstorm Nov 10, 2025
3413aa6
Pythonic asserts
kaapstorm Nov 19, 2025
92ed620
Add tests for `get_bulk()`
kaapstorm Nov 19, 2025
ac5532e
Add GET endpoint for external_id
kaapstorm Nov 19, 2025
0397732
Implement PUT for external_id
kaapstorm Nov 19, 2025
08a8cff
Move UPSERT logic back to `_get_bulk_updates()`
kaapstorm Nov 19, 2025
f3f38b8
Add and update tests
kaapstorm Nov 20, 2025
0ba43b9
isort
kaapstorm Dec 16, 2025
a1616f4
JsonCaseUpsert
kaapstorm Dec 16, 2025
7ed69d5
Add tests
kaapstorm Dec 16, 2025
e66b190
Pythonic asserts
kaapstorm Dec 16, 2025
7f4f7d3
Raise error if is_new_case not initialized
kaapstorm Jan 1, 2026
1bb8a1c
Check required properties late
kaapstorm Jan 1, 2026
eb0c4b5
Use path converter for `external_id`
kaapstorm Jan 1, 2026
b5e6323
Show behavior with repeated results
kaapstorm Jan 1, 2026
f6c8792
Simplify URL patterns
kaapstorm Jan 1, 2026
9f3f2ce
Simplify function params
kaapstorm Jan 1, 2026
3fb3360
Drop redundant checks
kaapstorm Jan 1, 2026
b717b1c
Simplify tests
kaapstorm Jan 1, 2026
2c5bc91
Consolidate tests
kaapstorm Jan 1, 2026
2ecb6b5
Pythonic asserts
kaapstorm Jan 1, 2026
61b7de6
Separate out single-case GET operations
kaapstorm Jan 1, 2026
5147d7c
Merge branch 'master' into nh/case_api_upsert
kaapstorm Jan 1, 2026
62653ee
Lint
kaapstorm Jan 5, 2026
6b715d1
Support optional trailing slash
kaapstorm Jan 7, 2026
9e66f86
Check PUT payload is a dict
kaapstorm Jan 13, 2026
80adcbe
Add tests
kaapstorm Jan 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions corehq/apps/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,19 @@ def versioned_apis(api_list):
messaging_events, name="api_messaging_event_list"),
url(r'messaging-event/(?P<api_version>v1)/(?P<event_id>\d+)/$',
messaging_events, name="api_messaging_event_detail"),

# Case API v0.6 endpoints
url(r'v0\.6/case/bulk-fetch/$', case_api_bulk_fetch),
# Trailing slash optional: https://github.com/dimagi/commcare-hq/pull/29939
url(r'v0.6/case/?$', case_api, name='case_api_v0.6'),
url(r'v0\.6/case/(?P<case_id>[\w\-,]+)/?$', case_api, name='case_api_v0.6_detail'),
path('v0.6/case/ext/<path:external_id>/', case_api),
# Case API v2 endpoints
url(r'case/v2/bulk-fetch/$', case_api_bulk_fetch, name='case_api_bulk_fetch'),
# match v0.6/case/ AND v0.6/case/e0ad6c2e-514c-4c2b-85a7-da35bbeb1ff1/ trailing slash optional
url(r'v0\.6/case(?:/(?P<case_id>[\w\-,]+))?/?$', case_api),
url(r'case/v2(?:/(?P<case_id>[\w\-,]+))?/?$', case_api, name='case_api'),
url(r'case/v2/?$', case_api, name='case_api'),
url(r'case/v2/(?P<case_id>[\w\-,]+)/?$', case_api, name='case_api_detail'),
path('case/v2/ext/<path:external_id>/', case_api, name='case_api_detail_ext'),

path('', include(list(versioned_apis(_OLD_API_LIST)))),
url(r'^case/attachment/(?P<case_id>[\w\-:]+)/(?P<attachment_id>.*)$', CaseAttachmentAPI.as_view()),
url(r'^case_attachment/v1/(?P<case_id>[\w\-:]+)/(?P<attachment_id>.*)$', CaseAttachmentAPI.as_view(),
Expand Down
95 changes: 72 additions & 23 deletions corehq/apps/hqcase/api/updates.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import uuid

from django.utils.functional import cached_property
from django.core.exceptions import PermissionDenied
from django.utils.functional import cached_property

import jsonobject
from jsonobject.exceptions import BadValueError

from casexml.apps.case.mock import CaseBlock, IndexAttrs

from corehq.apps.es.case_search import CaseSearchES
from corehq.apps.es.users import UserES
from corehq.apps.fixtures.utils import is_identifier_invalid
from corehq.apps.hqcase.utils import CASEBLOCK_CHUNKSIZE, submit_case_blocks
from corehq.form_processor.models import CommCareCase
from corehq.sql_db.util import get_db_aliases_for_partitioned_query
from corehq.apps.es.case_search import CaseSearchES
from corehq.apps.locations.models import SQLLocation
from corehq.apps.es.users import UserES
from corehq.apps.users.models import CouchUser
from corehq.form_processor.models import CommCareCase
from corehq.sql_db.util import get_db_aliases_for_partitioned_query

from .core import SubmissionError, UserError

Expand Down Expand Up @@ -76,7 +76,7 @@ class BaseJsonCaseChange(jsonobject.JsonObject):
properties = jsonobject.DictProperty(validators=[valid_properties_dict], default={})
indices = jsonobject.DictProperty(JsonIndex, validators=[valid_indices_dict])
close = jsonobject.BooleanProperty(default=False)
_is_case_creation = False
is_new_case = False

_allow_dynamic_properties = False

Expand All @@ -96,16 +96,22 @@ def wrap(cls, data):
def get_caseblock(self, case_db):

def get_kwargs(*attribs):
return {
kwargs = {
a: getattr(self, a)
for a in attribs
if getattr(self, a) is not None
}
if self.is_new_case:
required_kwargs = {'case_type', 'case_name', 'owner_id'}
if missing := required_kwargs - kwargs.keys():
ext_id = kwargs.get('external_id', '')
raise ValueError(f'{missing} required for new case {ext_id}')
Comment on lines +104 to +108

This comment was marked as outdated.

return kwargs

return CaseBlock(
case_id=self.get_case_id(case_db),
user_id=self.user_id,
create=self._is_case_creation,
create=self.is_new_case,
update=dict(self.properties),
close=self.close,
index={
Expand All @@ -118,10 +124,6 @@ def get_kwargs(*attribs):
**get_kwargs('case_type', 'case_name', 'external_id', 'owner_id'),
).as_text()

@property
def is_new_case(self):
return self._is_case_creation


class JsonCaseCreation(BaseJsonCaseChange):
temporary_id = jsonobject.StringProperty()
Expand All @@ -131,7 +133,7 @@ class JsonCaseCreation(BaseJsonCaseChange):
case_type = jsonobject.StringProperty(required=True)
owner_id = jsonobject.StringProperty(required=True)

_is_case_creation = True
is_new_case = True

@classmethod
def wrap(cls, data):
Expand All @@ -145,7 +147,7 @@ def get_case_id(self, case_db):


class JsonCaseUpdate(BaseJsonCaseChange):
_is_case_creation = False
is_new_case = False

def validate(self, *args, **kwargs):
super().validate(*args, **kwargs)
Expand All @@ -161,12 +163,44 @@ def get_case_id(self, case_db):
return case_db.get_by_external_id(self.external_id)


class JsonCaseUpsert(BaseJsonCaseChange):
"""Handles UPSERT operations where create/update is determined at lookup time."""
external_id = jsonobject.StringProperty(required=True)

_is_case_creation = ... # Determined when get_case_id() is called

@property
def is_new_case(self):
if self._is_case_creation is ...:
raise ValueError('is_new_case has not yet been initialized')
return self._is_case_creation
Comment on lines +173 to +176

This comment was marked as outdated.


@classmethod
def wrap(cls, data):
if 'case_id' in data:
raise UserError("UPSERT does not allow case_id to be specified")
return super().wrap(data)

def get_case_id(self, case_db):
if self.case_id:
return self.case_id

existing_case_id = case_db.get_upsert_case_id(self.external_id)
if existing_case_id:
self._is_case_creation = False
self.case_id = existing_case_id
else:
self._is_case_creation = True
self.case_id = str(uuid.uuid4())
return self.case_id


def handle_case_update(domain, data, user, device_id, is_creation, xmlns=None):
is_bulk = isinstance(data, list)
if is_bulk:
updates = _get_bulk_updates(domain, data, user)
updates = _get_bulk_updates(data, user.user_id)
else:
updates = [_get_individual_update(domain, data, user, is_creation)]
updates = [_get_individual_update(data, user.user_id, is_creation)]

case_db = CaseIDLookerUpper(domain, updates)

Expand All @@ -184,28 +218,39 @@ def handle_case_update(domain, data, user, device_id, is_creation, xmlns=None):
return xform, cases[0]


def _get_individual_update(domain, data, user, is_creation):
def _get_individual_update(data, user_id, is_creation):
update_class = JsonCaseCreation if is_creation else JsonCaseUpdate
data['user_id'] = user.user_id
data['user_id'] = user_id
try:
update = update_class.wrap(data)
except BadValueError as e:
raise UserError(str(e))
return update


def _get_bulk_updates(domain, all_data, user):
def _get_upsert_update(data, user_id):
data['user_id'] = user_id
try:
return JsonCaseUpsert.wrap(data)
except BadValueError as err:
raise UserError(str(err))


def _get_bulk_updates(all_data, user_id):
if len(all_data) > CASEBLOCK_CHUNKSIZE:
raise UserError(f"You cannot submit more than {CASEBLOCK_CHUNKSIZE} updates in a single request")

updates = []
errors = []
for i, data in enumerate(all_data, start=1):
try:
is_creation = data.pop('create', None)
if is_creation is None:
if 'create' not in data:
raise UserError("A 'create' flag is required for each update.")
updates.append(_get_individual_update(domain, data, user, is_creation))
create_flag = data.pop('create')
if create_flag is None:
updates.append(_get_upsert_update(data, user_id))
else:
updates.append(_get_individual_update(data, user_id, create_flag))
except UserError as e:
errors.append(f'Error in row {i}: {e}')

Expand Down Expand Up @@ -245,6 +290,10 @@ def get_by_external_id(self, key):
except KeyError:
raise UserError(f"Could not find a case with external_id '{key}'")

def get_upsert_case_id(self, external_id):
"""Returns case_id if found, None if not found (indicating creation)."""
return self._by_external_id.get(external_id)

@cached_property
def _by_external_id(self):
ids_in_request = {
Expand All @@ -254,7 +303,7 @@ def _by_external_id(self):

ids_to_find = {
update.external_id for update in self.updates
if not update._is_case_creation and not update.case_id
if not isinstance(update, JsonCaseCreation) and not update.case_id
} | {
index.external_id for update in self.updates for index in update.indices.values()
}
Expand Down
Loading
Loading