33from django .core .management .base import BaseCommand
44from pathlib import Path
55from ftva_lab_data .models import SheetImport
6- from django .db .models import ForeignKey , ManyToManyField
6+ from django .db .models import ForeignKey , ManyToManyField , DateField
77from django .core .exceptions import ObjectDoesNotExist
8+ from django .core .files .uploadedfile import InMemoryUploadedFile
9+ from typing import TypeAlias , Any
810
11+ ChangeDetails : TypeAlias = list [dict [str , Any ]]
912
10- def load_input_data (input_file : str ) -> list [list [dict ]]:
13+
14+ def load_input_data (input_file : str | InMemoryUploadedFile ) -> list [list [dict ]]:
1115 """Load input data from the input file into a list of sheets,
1216 as an input file may contain multiple sheets .
1317
14- :param input_file: Path to the spreadsheet containing records to update, as an XLSX file.
18+ :param input_file: Path to the spreadsheet containing records to update, as an XLSX file,
19+ or an InMemoryUploadedFile object passed from a Django form.
1520 :return: A list of lists of dicts, each representing a sheet with rows of input data.
1621 :raises ValueError: If the input file is not an XLSX file.
1722 """
18- input_suffix = Path (input_file ).suffix
19- if input_suffix != ".xlsx" :
20- raise ValueError (f"Unsupported file type: { input_suffix } " )
23+ # Check extension if input_file is a string path
24+ if isinstance (input_file , str ):
25+ input_suffix = Path (input_file ).suffix
26+ if input_suffix != ".xlsx" :
27+ raise ValueError (f"Unsupported file type: { input_suffix } " )
2128 # `sheet_name=None` reads all sheets
2229 sheets = pd .read_excel (input_file , sheet_name = None )
30+
2331 # Convert each sheet DataFrame to a list of dicts, each representing a sheet of input data,
2432 # filling NA with empty string to avoid type issues with Django
2533 return [
@@ -77,9 +85,11 @@ def batch_update(input_data: list[dict], dry_run: bool) -> int:
7785 or if no updates were made to any records.
7886 """
7987 records_updated = 0
88+ invalid_values : ChangeDetails = [] # track invalid values for whole batch
8089 for row in input_data :
8190 record = SheetImport .objects .get (id = row ["id" ])
82- has_changes = False
91+ record_changes : ChangeDetails = [] # changes to non-m2m fields for each record
92+ many_to_many_changes : ChangeDetails = [] # changes to m2m need special handling
8393 for field , value in row .items ():
8494 # Guard against changes to IDs or UUIDs
8595 if field .lower () in ["id" , "pk" , "uuid" ]:
@@ -107,11 +117,12 @@ def batch_update(input_data: list[dict], dry_run: bool) -> int:
107117 ** {f"{ field } __istartswith" : value }
108118 )
109119 if current_value != update :
110- has_changes = True
111- setattr (record , field , update )
112- print (
113- f"Record { row ['id' ]} updated: "
114- f"{ field } changed from { current_value } to { update } "
120+ record_changes .append (
121+ {
122+ "field" : field ,
123+ "from" : current_value ,
124+ "to" : update ,
125+ }
115126 )
116127
117128 # Else if the field is a ManyToManyField,
@@ -127,45 +138,107 @@ def batch_update(input_data: list[dict], dry_run: bool) -> int:
127138 )
128139 # Only apply update if there is one and it's not already in the m2m relationship
129140 if update and update not in current_related_objects :
130- has_changes = True
131- # Need to use `getattr().add()` here rather than `setattr()`,
132- # since we're adding an object to a many-to-many relationship,
133- # rather than setting a single foreign key as we do above.
134- # `add()` immediately saves the change to the database though,
135- # so we need an additional `dry_run` check.
136- if not dry_run :
137- getattr (record , field ).add (update )
138- print (
139- f"Record { row ['id' ]} updated: " f"added { update } to { field } "
141+ # Since we're adding an object to a many-to-many relationship,
142+ # rather than setting a single foreign key as we do above,
143+ # track these changes separately,
144+ # and apply them later after all changes to record are collected.
145+ many_to_many_changes .append (
146+ {
147+ "field" : field ,
148+ "update" : update ,
149+ }
150+ )
151+
152+ # Else if the field is a DateField,
153+ # try parsing the value as a date,
154+ # and collect invalid dates for later reporting.
155+ elif isinstance (field_object , DateField ):
156+ current_value = getattr (record , field )
157+ if value == "" :
158+ update = None
159+ else :
160+ try :
161+ update = pd .to_datetime (value ).date ()
162+ except ValueError :
163+ invalid_values .append (
164+ {
165+ "record_id" : row ["id" ],
166+ "field" : field ,
167+ "value" : value ,
168+ }
169+ )
170+ continue # don't apply change if date is invalid
171+ if current_value != update :
172+ record_changes .append (
173+ {
174+ "field" : field ,
175+ "from" : current_value ,
176+ "to" : update ,
177+ }
140178 )
141179
142180 # Otherwise, just set the value directly
143181 else :
144182 # Replace any empty strings in `file_name` with "NO FILE NAME"
145183 if field == "file_name" and value == "" :
146184 value = "NO FILE NAME"
147- current_value = getattr (record , field )
185+ # Coerce current database value to string for comparison
186+ current_value = str (getattr (record , field ))
148187 if current_value != value :
149- has_changes = True
150- setattr (record , field , value )
151- print (
152- f"Record { row ['id' ]} updated: "
153- f"{ field } changed from { current_value } to { value } "
188+ record_changes .append (
189+ {
190+ "field" : field ,
191+ "from" : current_value ,
192+ "to" : value ,
193+ }
154194 )
155195 except ObjectDoesNotExist as e :
156196 # Handler expects a ValueError
157197 raise ValueError (
158198 f"Error applying value { value } to field { field } on record { row ['id' ]} : { e } "
159199 )
160200
161- # Compare the original record to the updated record
162- if not has_changes :
163- print (f"No changes were made to record { row ['id' ]} " )
201+ # Continue if no changes made to current record
202+ if not record_changes and not many_to_many_changes :
203+ print (f"No changes made to record { row ['id' ]} " )
164204 continue
205+ # Save changes to record if not a dry run
165206 if not dry_run :
207+ # Apply record changes that can be set via `setattr()`...
208+ for change in record_changes :
209+ setattr (record , change ["field" ], change ["to" ])
210+ # then apply many-to-many changes,
211+ # which require use of `.add()` rather than `.setattr()`.
212+ # Note that `.add()` saves changes immediately.
213+ for change in many_to_many_changes :
214+ getattr (record , change ["field" ]).add (change ["update" ])
215+ # Now save record changes to the database.
166216 record .save ()
217+ # Report on changes made to each record
218+ for change in record_changes :
219+ print (
220+ f"Record { row ['id' ]} updated: "
221+ f"{ change ['field' ]} changed "
222+ f"from { change ['from' ] if change ['from' ] else '""' } "
223+ f"to { change ['to' ] if change ['to' ] else '""' } "
224+ )
225+ for change in many_to_many_changes :
226+ print (
227+ f"Record { row ['id' ]} updated: "
228+ f"{ change ['update' ]} added to { change ['field' ]} "
229+ )
167230 records_updated += 1
168231
232+ # Report on invalid values here so as not to prevent other valid changes from being applied
233+ if invalid_values :
234+ report_lines = [
235+ f"Record { invalid_value ['record_id' ]} { invalid_value ['field' ]} { invalid_value ['value' ]} "
236+ for invalid_value in invalid_values
237+ ]
238+ raise ValueError (
239+ "Invalid values found in input data:\n " + "\n " .join (report_lines )
240+ )
241+
169242 if records_updated == 0 :
170243 raise ValueError ("All inputs match existing records; no updates to apply." )
171244 return records_updated
0 commit comments