Skip to content

Commit f0854d4

Browse files
feat: add delete-impact endpoints
1 parent e9b851c commit f0854d4

8 files changed

Lines changed: 408 additions & 10 deletions

File tree

data/countries.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,13 +255,51 @@ def get_dependent_states_count(country_code: str) -> int:
255255
return len(states_list)
256256

257257

258+
def get_dependent_cities_count(country_code: str) -> int:
259+
"""
260+
Check how many cities belong to this country.
261+
"""
262+
import data.cities as cities
263+
264+
cities_list = cities.get_cities_by_country(country_code)
265+
return len(cities_list)
266+
267+
268+
def get_country_delete_impact(country_code: str) -> dict | None:
269+
"""
270+
Return dependency counts that would be affected by deleting a country.
271+
Returns None when the country does not exist.
272+
"""
273+
normalized_code = country_code.upper()
274+
country = get_country_by_code(normalized_code)
275+
if not country:
276+
return None
277+
278+
states_count = get_dependent_states_count(normalized_code)
279+
cities_count = get_dependent_cities_count(normalized_code)
280+
281+
return {
282+
COUNTRY_CODE: normalized_code,
283+
"exists": True,
284+
"states": states_count,
285+
"cities": cities_count,
286+
"direct_dependency_count": states_count,
287+
"total_dependency_count": states_count + cities_count,
288+
"blocked": states_count > 0,
289+
}
290+
291+
258292
def can_delete_country(country_code: str) -> tuple[bool, str]:
259293
"""
260294
Check if country can be safely deleted.
261295
Returns (can_delete: bool, reason: str)
262296
"""
263-
dependent_count = get_dependent_states_count(country_code)
264-
if dependent_count > 0:
297+
delete_impact = get_country_delete_impact(country_code)
298+
if delete_impact is None:
299+
return True, ""
300+
301+
dependent_count = delete_impact["states"]
302+
if delete_impact["blocked"]:
265303
return (
266304
False,
267305
f"Cannot delete: {dependent_count} state(s) depend on this country",

data/states.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,13 +207,37 @@ def get_dependent_cities_count(state_code: str) -> int:
207207
return len(cities_list)
208208

209209

210+
def get_state_delete_impact(state_code: str) -> dict | None:
211+
"""
212+
Return dependency counts for deleting a state.
213+
"""
214+
normalized_code = state_code.upper()
215+
state = get_state_by_code(normalized_code)
216+
if not state:
217+
return None
218+
219+
cities_count = get_dependent_cities_count(normalized_code)
220+
return {
221+
STATE_CODE: normalized_code,
222+
"exists": True,
223+
"cities": cities_count,
224+
"direct_dependency_count": cities_count,
225+
"total_dependency_count": cities_count,
226+
"blocked": cities_count > 0,
227+
}
228+
229+
210230
def can_delete_state(state_code: str) -> tuple[bool, str]:
211231
"""
212232
Check if state can be safely deleted.
213233
Returns (can_delete: bool, reason: str)
214234
"""
215-
dependent_count = get_dependent_cities_count(state_code)
216-
if dependent_count > 0:
235+
delete_impact = get_state_delete_impact(state_code)
236+
if delete_impact is None:
237+
return True, ""
238+
239+
dependent_count = delete_impact["cities"]
240+
if delete_impact["blocked"]:
217241
return False, f"Cannot delete: {dependent_count} city/cities depend on this state"
218242
return True, ""
219243

data/tests/test_countries.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,24 +192,73 @@ def test_delete_country_not_found(self):
192192

193193
def test_delete_country_with_dependent_states(self):
194194
"""Test that deleting a country with states raises ValueError."""
195-
with patch('data.countries.get_dependent_states_count', return_value=5):
195+
with patch('data.countries.can_delete_country', return_value=(False, "Cannot delete: 5 state(s) depend on this country")):
196196
with pytest.raises(ValueError, match="Cannot delete: 5 state"):
197197
countries.delete_country('US')
198198

199199
def test_can_delete_country_with_dependencies(self):
200200
"""Test can_delete_country returns False when states exist."""
201-
with patch('data.countries.get_dependent_states_count', return_value=3):
201+
with patch('data.countries.get_country_delete_impact', return_value={
202+
countries.COUNTRY_CODE: 'US',
203+
'exists': True,
204+
'states': 3,
205+
'cities': 8,
206+
'direct_dependency_count': 3,
207+
'total_dependency_count': 11,
208+
'blocked': True,
209+
}):
202210
can_delete, reason = countries.can_delete_country('US')
203211
assert can_delete is False
204212
assert "3 state" in reason
205213

206214
def test_can_delete_country_no_dependencies(self):
207215
"""Test can_delete_country returns True when no states exist."""
208-
with patch('data.countries.get_dependent_states_count', return_value=0):
216+
with patch('data.countries.get_dependent_states_count', return_value=0), \
217+
patch('data.countries.get_dependent_cities_count', return_value=0), \
218+
patch('data.countries.get_country_by_code', return_value=countries.TEST_COUNTRY):
209219
can_delete, reason = countries.can_delete_country('XX')
210220
assert can_delete is True
211221
assert reason == ""
212222

223+
def test_get_country_delete_impact_zero_dependencies(self):
224+
"""Delete impact reports zero totals when no dependent states or cities exist."""
225+
with patch('data.countries.get_country_by_code', return_value=countries.TEST_COUNTRY), \
226+
patch('data.countries.get_dependent_states_count', return_value=0), \
227+
patch('data.countries.get_dependent_cities_count', return_value=0):
228+
impact = countries.get_country_delete_impact('us')
229+
230+
assert impact == {
231+
countries.COUNTRY_CODE: 'US',
232+
'exists': True,
233+
'states': 0,
234+
'cities': 0,
235+
'direct_dependency_count': 0,
236+
'total_dependency_count': 0,
237+
'blocked': False,
238+
}
239+
240+
def test_get_country_delete_impact_includes_total_cities(self):
241+
"""Delete impact total counts include both direct states and nested cities."""
242+
with patch('data.countries.get_country_by_code', return_value=countries.TEST_COUNTRY), \
243+
patch('data.countries.get_dependent_states_count', return_value=2), \
244+
patch('data.countries.get_dependent_cities_count', return_value=7):
245+
impact = countries.get_country_delete_impact('us')
246+
247+
assert impact == {
248+
countries.COUNTRY_CODE: 'US',
249+
'exists': True,
250+
'states': 2,
251+
'cities': 7,
252+
'direct_dependency_count': 2,
253+
'total_dependency_count': 9,
254+
'blocked': True,
255+
}
256+
257+
def test_get_country_delete_impact_not_found(self):
258+
"""Delete impact returns None when the country does not exist."""
259+
with patch('data.countries.get_country_by_code', return_value=None):
260+
assert countries.get_country_delete_impact('xx') is None
261+
213262
def test_country_exists_true(self):
214263
"""Test checking if a country exists - returns True."""
215264
with patch('data.countries.get_country_by_code') as mock_get:

data/tests/test_states.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -282,19 +282,68 @@ def test_delete_state_with_dependent_cities(self):
282282
mock_can_delete.assert_called_once_with("NY")
283283

284284
def test_can_delete_state_with_dependencies(self):
285-
"""Test can_delete_state returns False when cities exist."""
286-
with patch("data.states.get_dependent_cities_count", return_value=5):
285+
"""Test can_delete_state returns False when delete impact is blocked."""
286+
with patch(
287+
"data.states.get_state_delete_impact",
288+
return_value={
289+
S.STATE_CODE: "NY",
290+
"exists": True,
291+
"cities": 5,
292+
"direct_dependency_count": 5,
293+
"total_dependency_count": 5,
294+
"blocked": True,
295+
},
296+
):
287297
can_delete, reason = S.can_delete_state("NY")
288298
assert can_delete is False
289299
assert "5 city" in reason
290300

291301
def test_can_delete_state_no_dependencies(self):
292302
"""Test can_delete_state returns True when no cities exist."""
293-
with patch("data.states.get_dependent_cities_count", return_value=0):
303+
with patch("data.states.get_dependent_cities_count", return_value=0), patch(
304+
"data.states.get_state_by_code", return_value=S.TEST_STATE
305+
):
294306
can_delete, reason = S.can_delete_state("XX")
295307
assert can_delete is True
296308
assert reason == ""
297309

310+
def test_get_state_delete_impact_zero_dependencies(self):
311+
"""Delete impact reports zero totals when no dependent cities exist."""
312+
with patch("data.states.get_state_by_code", return_value=S.TEST_STATE), patch(
313+
"data.states.get_dependent_cities_count", return_value=0
314+
):
315+
impact = S.get_state_delete_impact("ny")
316+
317+
assert impact == {
318+
S.STATE_CODE: "NY",
319+
"exists": True,
320+
"cities": 0,
321+
"direct_dependency_count": 0,
322+
"total_dependency_count": 0,
323+
"blocked": False,
324+
}
325+
326+
def test_get_state_delete_impact_with_dependencies(self):
327+
"""Delete impact reports dependency counts and blocking when cities exist."""
328+
with patch("data.states.get_state_by_code", return_value=S.TEST_STATE), patch(
329+
"data.states.get_dependent_cities_count", return_value=3
330+
):
331+
impact = S.get_state_delete_impact("ny")
332+
333+
assert impact == {
334+
S.STATE_CODE: "NY",
335+
"exists": True,
336+
"cities": 3,
337+
"direct_dependency_count": 3,
338+
"total_dependency_count": 3,
339+
"blocked": True,
340+
}
341+
342+
def test_get_state_delete_impact_not_found(self):
343+
"""Delete impact returns None when the state does not exist."""
344+
with patch("data.states.get_state_by_code", return_value=None):
345+
assert S.get_state_delete_impact("zz") is None
346+
298347
# ===== Existence check tests =====
299348

300349
def test_state_exists(self):

server/countries_endpoints.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,41 @@ def add_country_links(country: dict) -> dict:
218218
},
219219
)
220220

221+
delete_impact_model = countries_ns.model(
222+
"CountryDeleteImpact",
223+
{
224+
"country_code": fields.String(
225+
required=True, description="ISO 3166-1 alpha-2 country code", example="US"
226+
),
227+
"exists": fields.Boolean(
228+
required=True,
229+
description="Whether the requested country exists",
230+
example=True,
231+
),
232+
"states": fields.Integer(
233+
required=True, description="Number of directly dependent states", example=5
234+
),
235+
"cities": fields.Integer(
236+
required=True, description="Number of cascade-affected cities", example=120
237+
),
238+
"direct_dependency_count": fields.Integer(
239+
required=True,
240+
description="Total number of directly dependent records that block a non-cascade delete",
241+
example=5,
242+
),
243+
"total_dependency_count": fields.Integer(
244+
required=True,
245+
description="Total number of dependent records affected by cascade delete",
246+
example=125,
247+
),
248+
"blocked": fields.Boolean(
249+
required=True,
250+
description="Whether safe delete is blocked by direct dependencies",
251+
example=True,
252+
),
253+
},
254+
)
255+
221256
list_parser = reqparse.RequestParser()
222257
list_parser.add_argument(
223258
"limit",
@@ -341,6 +376,35 @@ def get(self, country_code):
341376
)
342377

343378

379+
@countries_ns.route("/<string:country_code>/delete-impact")
380+
@countries_ns.param("country_code", "The country code (ISO 3166-1 alpha-2)")
381+
class CountryDeleteImpact(Resource):
382+
"""Country delete impact endpoint"""
383+
384+
@countries_ns.doc("get_country_delete_impact")
385+
@countries_ns.marshal_with(delete_impact_model)
386+
@countries_ns.response(HTTPStatus.NOT_FOUND, "Country not found", error_model)
387+
def get(self, country_code):
388+
"""
389+
Retrieve dependency counts for deleting a country.
390+
Returns counts the UI can use before confirming delete.
391+
"""
392+
try:
393+
impact = countries_data.get_country_delete_impact(country_code.upper())
394+
except Exception as e:
395+
countries_ns.abort(
396+
HTTPStatus.INTERNAL_SERVER_ERROR, f"Database error: {str(e)}"
397+
)
398+
399+
if impact:
400+
return impact, HTTPStatus.OK
401+
402+
countries_ns.abort(
403+
HTTPStatus.NOT_FOUND,
404+
f"Country with code '{country_code}' not found",
405+
)
406+
407+
344408
@countries_ns.route("/<string:country_code>")
345409
@countries_ns.param("country_code", "The country code (ISO 3166-1 alpha-2)")
346410
class Country(Resource):

server/states_endpoints.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,38 @@
112112
},
113113
)
114114

115+
delete_impact_model = states_ns.model(
116+
"StateDeleteImpact",
117+
{
118+
"state_code": fields.String(
119+
required=True, description="State code (e.g., CA, NY)", example="CA"
120+
),
121+
"exists": fields.Boolean(
122+
required=True,
123+
description="Whether the requested state exists",
124+
example=True,
125+
),
126+
"cities": fields.Integer(
127+
required=True, description="Number of directly dependent cities", example=12
128+
),
129+
"direct_dependency_count": fields.Integer(
130+
required=True,
131+
description="Total number of directly dependent records that block a non-cascade delete",
132+
example=12,
133+
),
134+
"total_dependency_count": fields.Integer(
135+
required=True,
136+
description="Total number of dependent records affected by cascade delete",
137+
example=12,
138+
),
139+
"blocked": fields.Boolean(
140+
required=True,
141+
description="Whether safe delete is blocked by direct dependencies",
142+
example=True,
143+
),
144+
},
145+
)
146+
115147
# Parser for query parameters on the GET /states endpoint
116148
list_parser = reqparse.RequestParser()
117149
list_parser.add_argument(
@@ -241,6 +273,34 @@ def post(self):
241273
)
242274

243275

276+
@states_ns.route("/<string:state_code>/delete-impact")
277+
@states_ns.param("state_code", "The state code (e.g., CA, NY)")
278+
class StateDeleteImpact(Resource):
279+
"""State delete impact endpoint"""
280+
281+
@states_ns.doc("get_state_delete_impact")
282+
@states_ns.marshal_with(delete_impact_model)
283+
@states_ns.response(HTTPStatus.NOT_FOUND, "State not found", error_model)
284+
def get(self, state_code: str):
285+
"""
286+
Retrieve dependency counts for deleting a state.
287+
Returns counts the UI can use before confirming delete.
288+
"""
289+
try:
290+
impact = states_data.get_state_delete_impact(state_code.upper())
291+
except Exception as e:
292+
states_ns.abort(
293+
HTTPStatus.INTERNAL_SERVER_ERROR, f"Database error: {str(e)}"
294+
)
295+
296+
if impact:
297+
return impact, HTTPStatus.OK
298+
299+
states_ns.abort(
300+
HTTPStatus.NOT_FOUND, f"State with code '{state_code}' not found"
301+
)
302+
303+
244304
@states_ns.route("/<string:state_code>")
245305
@states_ns.param("state_code", "The state code (e.g., CA, NY)")
246306
class State(Resource):

0 commit comments

Comments
 (0)