11from datetime import date
2- from datetime import datetime
32
43import click
54from pandas import concat
65from pandas import DataFrame
76from pandas import to_datetime
8- from pandas import to_timedelta
97from py42 import exceptions
108from py42 .exceptions import Py42NotFoundError
119
1210from code42cli .bulk import run_bulk_process
1311from code42cli .click_ext .groups import OrderedGroup
12+ from code42cli .click_ext .options import incompatible_with
13+ from code42cli .click_ext .types import MagicDate
14+ from code42cli .date_helper import round_datetime_to_day_end
15+ from code42cli .date_helper import round_datetime_to_day_start
1416from code42cli .errors import Code42CLIError
1517from code42cli .file_readers import read_csv_arg
1618from code42cli .options import format_option
@@ -33,8 +35,7 @@ def devices(state):
3335 required = False ,
3436 is_flag = True ,
3537 default = False ,
36- help = """Prepend "deactivated_" and today's date to the name of any
37- deactivated devices.""" ,
38+ help = "Prepend 'deactivated_<current_date>' to the name of any deactivated devices." ,
3839)
3940
4041DATE_FORMAT = "%Y-%m-%d"
@@ -43,8 +44,8 @@ def devices(state):
4344 required = False ,
4445 type = click .DateTime (formats = [DATE_FORMAT ]),
4546 default = None ,
46- help = """ The date on which the archive should be purged from cold storage in yyyy-MM-dd format.
47- If not provided, the date will be set according to the appropriate org settings."" " ,
47+ help = "The date on which the archive should be purged from cold storage in yyyy-MM-dd format. "
48+ " If not provided, the date will be set according to the appropriate org settings." ,
4849)
4950
5051
@@ -59,8 +60,12 @@ def deactivate(state, device_guid, change_device_name, purge_date):
5960
6061
6162def _deactivate_device (sdk , device_guid , change_device_name , purge_date ):
62- device = sdk .devices .get_by_guid (device_guid )
6363 try :
64+ int (device_guid )
65+ except ValueError :
66+ raise Code42CLIError ("Not a valid guid." )
67+ try :
68+ device = sdk .devices .get_by_guid (device_guid )
6469 sdk .devices .deactivate (device .data ["computerId" ])
6570 except exceptions .Py42BadRequestError :
6671 raise Code42CLIError ("The device {} is in legal hold." .format (device_guid ))
@@ -103,10 +108,9 @@ def _change_device_name(sdk, guid, name):
103108
104109@devices .command ()
105110@device_guid_argument
106- @format_option
107111@sdk_options ()
108112def show (state , device_guid , format = None ):
109- """Print device info. Requires device GUID."""
113+ """Print individual device info. Requires device GUID."""
110114
111115 formatter = OutputFormatter (format , _device_info_keys_map ())
112116 backup_set_formatter = OutputFormatter (format , _backup_set_keys_map ())
@@ -145,20 +149,23 @@ def _get_device_info(sdk, device_guid):
145149
146150active_option = click .option (
147151 "--active" ,
148- required = False ,
149- type = bool ,
150152 is_flag = True ,
153+ help = "Limits results to only active devices." ,
151154 default = None ,
152- help = "Get only active or deactivated devices. Defaults to getting all devices." ,
153155)
154-
156+ inactive_option = click .option (
157+ "--inactive" ,
158+ is_flag = True ,
159+ help = "Limits results to only deactivated devices." ,
160+ cls = incompatible_with ("active" ),
161+ )
155162org_uid_option = click .option (
156163 "--org-uid" ,
157164 required = False ,
158165 type = str ,
159166 default = None ,
160- help = """ Limit devices to only the ones in the org you specify.
161- Note that child orgs will be included."" " ,
167+ help = "Limit devices to only the ones in the org you specify. "
168+ " Note that child orgs will be included." ,
162169)
163170
164171include_usernames_option = click .option (
@@ -171,31 +178,18 @@ def _get_device_info(sdk, device_guid):
171178)
172179
173180
174- @devices .command (name = "list" , help = "Get information about many devices" )
181+ @devices .command (name = "list" )
175182@active_option
176- @click .option (
177- "--days-since-last-connected" ,
178- required = False ,
179- type = int ,
180- help = "Return only devices that have not connected in the number of days specified." ,
181- )
183+ @inactive_option
182184@org_uid_option
183- @click .option (
184- "--drop-most-recent" ,
185- required = False ,
186- type = int ,
187- help = """Will drop the X most recently connected devices for each user from the
188- result list where X is the number you provide as this argument. Can be used to
189- avoid passing the most recently connected device for a user to the deactivate command.""" ,
190- )
191185@click .option (
192186 "--include-backup-usage" ,
193187 required = False ,
194188 type = bool ,
195189 default = False ,
196190 is_flag = True ,
197- help = """ Return backup usage information for each device
198- (may significantly lengthen the size of the return)."" " ,
191+ help = "Return backup usage information for each device (may significantly lengthen the size "
192+ " of the return)." ,
199193)
200194@include_usernames_option
201195@click .option (
@@ -204,53 +198,99 @@ def _get_device_info(sdk, device_guid):
204198 type = bool ,
205199 default = False ,
206200 is_flag = True ,
207- help = """Include device settings in output.""" ,
201+ help = "Include device settings in output." ,
202+ )
203+ @click .option (
204+ "--exclude-most-recently-connected" ,
205+ type = int ,
206+ help = "Filter out the N most recently connected devices per user. "
207+ "Useful for identifying duplicate and/or replaced devices that are no longer needed across "
208+ "an environment. If a user has 2 devices and N=1, the one device with the most recent "
209+ "'lastConnected' date will not show up in the result list." ,
210+ )
211+ @click .option (
212+ "--last-connected-before" ,
213+ type = MagicDate (rounding_func = round_datetime_to_day_start ),
214+ help = f"Include devices only when the 'lastConnected' field is after the provided value. { MagicDate .HELP_TEXT } " ,
215+ )
216+ @click .option (
217+ "--last-connected-after" ,
218+ type = MagicDate (rounding_func = round_datetime_to_day_end ),
219+ help = "Include devices only when 'lastConnected' field is after the provided value. "
220+ "Argument format options are the same as --last-connected-before." ,
221+ )
222+ @click .option (
223+ "--created-before" ,
224+ type = MagicDate (rounding_func = round_datetime_to_day_start ),
225+ help = "Include devices only when 'creationDate' field is less than the provided value. "
226+ "Argument format options are the same as --last-connected-before." ,
227+ )
228+ @click .option (
229+ "--created-after" ,
230+ type = MagicDate (rounding_func = round_datetime_to_day_end ),
231+ help = "Include devices only when 'creationDate' field is greater than the provided value. "
232+ "Argument format options are the same as --last-connected-before." ,
208233)
209234@format_option
210235@sdk_options ()
211236def list_devices (
212237 state ,
213238 active ,
214- days_since_last_connected ,
215- drop_most_recent ,
239+ inactive ,
216240 org_uid ,
217241 include_backup_usage ,
218242 include_usernames ,
219243 include_settings ,
244+ exclude_most_recently_connected ,
245+ last_connected_after ,
246+ last_connected_before ,
247+ created_after ,
248+ created_before ,
220249 format ,
221250):
222- """Outputs a list of all devices."""
251+ """Get information about many devices."""
252+ if inactive :
253+ active = False
223254 columns = [
224255 "computerId" ,
225256 "guid" ,
226257 "name" ,
227258 "osHostname" ,
228259 "status" ,
229260 "lastConnected" ,
261+ "creationDate" ,
230262 "productVersion" ,
231263 "osName" ,
232264 "osVersion" ,
233265 "userUid" ,
234266 ]
235- devices_dataframe = _get_device_dataframe (
267+ df = _get_device_dataframe (
236268 state .sdk , columns , active , org_uid , include_backup_usage
237269 )
238- if drop_most_recent :
239- devices_dataframe = _drop_n_devices_per_user (
240- devices_dataframe , drop_most_recent
241- )
242- if days_since_last_connected :
243- devices_dataframe = _drop_devices_which_have_not_connected_in_some_number_of_days (
244- devices_dataframe , days_since_last_connected
270+ if last_connected_after :
271+ df = df .loc [to_datetime (df .lastConnected ) > last_connected_after ]
272+ if last_connected_before :
273+ df = df .loc [to_datetime (df .lastConnected ) < last_connected_before ]
274+ if created_after :
275+ df = df .loc [to_datetime (df .creationDate ) > created_after ]
276+ if created_before :
277+ df = df .loc [to_datetime (df .creationDate ) < created_before ]
278+ if exclude_most_recently_connected :
279+ most_recent = (
280+ df .sort_values (["userUid" , "lastConnected" ], ascending = False )
281+ .groupby ("userUid" )
282+ .head (exclude_most_recently_connected )
245283 )
284+ df = df .drop (most_recent .index )
246285 if include_settings :
247- devices_dataframe = _add_settings_to_dataframe (state .sdk , devices_dataframe )
286+ df = _add_settings_to_dataframe (state .sdk , df )
248287 if include_usernames :
249- devices_dataframe = _add_usernames_to_device_dataframe (
250- state .sdk , devices_dataframe
251- )
252- formatter = DataFrameOutputFormatter (format )
253- formatter .echo_formatted_dataframe (devices_dataframe )
288+ df = _add_usernames_to_device_dataframe (state .sdk , df )
289+ if df .empty :
290+ click .echo ("No results found." )
291+ else :
292+ formatter = DataFrameOutputFormatter (format )
293+ formatter .echo_formatted_dataframe (df )
254294
255295
256296def _get_device_dataframe (
@@ -297,35 +337,6 @@ def handle_row(guid):
297337 return device_dataframe
298338
299339
300- def _drop_devices_which_have_not_connected_in_some_number_of_days (
301- devices_dataframe , days_since_last_connected
302- ):
303- utc_now = to_datetime (datetime .utcnow (), utc = True )
304- devices_last_connected_dates = to_datetime (
305- devices_dataframe ["lastConnected" ], utc = True
306- )
307- days_since_last_connected_delta = to_timedelta (
308- days_since_last_connected , unit = "days"
309- )
310- return devices_dataframe .loc [
311- utc_now - devices_last_connected_dates > days_since_last_connected_delta , :,
312- ]
313-
314-
315- def _drop_n_devices_per_user (
316- device_dataframe ,
317- number_to_drop ,
318- sort_field = "lastConnected" ,
319- sort_ascending = False ,
320- group_field = "userUid" ,
321- ):
322- return (
323- device_dataframe .sort_values (by = sort_field , ascending = sort_ascending )
324- .drop (device_dataframe .groupby (group_field ).head (number_to_drop ).index )
325- .reset_index (drop = True )
326- )
327-
328-
329340def _add_usernames_to_device_dataframe (sdk , device_dataframe ):
330341 users_generator = sdk .users .get_all ()
331342 users_list = []
@@ -337,30 +348,29 @@ def _add_usernames_to_device_dataframe(sdk, device_dataframe):
337348 return device_dataframe .merge (users_dataframe , how = "left" , on = "userUid" )
338349
339350
340- @devices .command (
341- name = "list-backup-sets" ,
342- help = "Get information about many devices and their backup sets" ,
343- )
351+ @devices .command ()
344352@active_option
353+ @inactive_option
345354@org_uid_option
346355@include_usernames_option
347356@format_option
348357@sdk_options ()
349358def list_backup_sets (
350- state , active , org_uid , include_usernames , format ,
359+ state , active , inactive , org_uid , include_usernames , format ,
351360):
352- """Outputs a list of all devices."""
361+ """Get information about many devices and their backup sets."""
362+ if inactive :
363+ active = False
353364 columns = ["guid" , "userUid" ]
354- devices_dataframe = _get_device_dataframe (state .sdk , columns , active , org_uid )
365+ df = _get_device_dataframe (state .sdk , columns , active , org_uid )
355366 if include_usernames :
356- devices_dataframe = _add_usernames_to_device_dataframe (
357- state .sdk , devices_dataframe
358- )
359- devices_dataframe = _add_backup_set_settings_to_dataframe (
360- state .sdk , devices_dataframe
361- )
362- formatter = DataFrameOutputFormatter (format )
363- formatter .echo_formatted_dataframe (devices_dataframe )
367+ df = _add_usernames_to_device_dataframe (state .sdk , df )
368+ df = _add_backup_set_settings_to_dataframe (state .sdk , df )
369+ if df .empty :
370+ click .echo ("No results found." )
371+ else :
372+ formatter = DataFrameOutputFormatter (format )
373+ formatter .echo_formatted_dataframe (df )
364374
365375
366376def _add_backup_set_settings_to_dataframe (sdk , devices_dataframe ):
@@ -414,17 +424,14 @@ def bulk(state):
414424 pass
415425
416426
417- @bulk .command (
418- name = "deactivate" ,
419- help = """Deactivate all devices on the given list.
420- Takes as input a CSV with a 'guid' column.""" ,
421- )
427+ @bulk .command (name = "deactivate" )
422428@read_csv_arg (headers = ["guid" ])
423429@change_device_name_option
424430@purge_date_option
425431@format_option
426432@sdk_options ()
427433def bulk_deactivate (state , csv_rows , change_device_name , purge_date , format ):
434+ """Deactivate all devices from the provided CSV containing a 'guid' column."""
428435 sdk = state .sdk
429436 csv_rows [0 ]["deactivated" ] = False
430437 formatter = OutputFormatter (format , {key : key for key in csv_rows [0 ].keys ()})
0 commit comments