From d5255615fd270f4f70ef6d07a87e879ebe1a8eef Mon Sep 17 00:00:00 2001 From: Ross Scroggs Date: Sun, 18 Feb 2024 20:59:58 -0800 Subject: [PATCH] Added `use_classroom_owner_access` Boolean variable to `gam.cfg` --- docs/Classroom-Invitations.md | 40 ++- docs/Find-File-Owner.md | 5 +- docs/GamUpdates.md | 20 ++ docs/How-to-Upgrade-from-Standard-GAM.md | 4 +- docs/Other-Resources.md | 1 + docs/Version-and-Help.md | 12 +- docs/gam.cfg.md | 21 +- src/GamCommands.txt | 15 +- src/GamUpdate.txt | 20 ++ src/gam/__init__.py | 436 ++++++++++++++--------- 10 files changed, 363 insertions(+), 211 deletions(-) diff --git a/docs/Classroom-Invitations.md b/docs/Classroom-Invitations.md index a5b35076..8df313c8 100644 --- a/docs/Classroom-Invitations.md +++ b/docs/Classroom-Invitations.md @@ -3,9 +3,10 @@ - [Notes](#notes) - [Definitions](#definitions) - [Create classroom invitations](#create-classroom-invitations) -- [Accept classroom invitations](#accept-classroom-invitations) -- [Delete classroom invitations](#delete-classroom-invitations) +- [Accept classroom invitations by user](#accept-classroom-invitations-by-user) +- [Delete classroom invitations by user](#delete-classroom-invitations-by-user) - [Display classroom invitations by user](#display-classroom-invitations-by-user) +- [Delete classroom invitations by course](#delete-classroom-invitations-by-course) - [Display classroom invitations by course](#display-classroom-invitations-by-course) ## API documentation @@ -24,8 +25,6 @@ Scope: https://www.googleapis.com/auth/classroom.rosters , Checked: FA ``` Follow the directions to authorize the Service Account scopes. -The Classroom API does not support inviting users from outside your domain. - ## Definitions ``` ::= (.)+ @@ -49,12 +48,18 @@ The Classroom API does not support inviting users from outside your domain. Invite users to classes. ``` gam create classroominvitation courses [role owner|student|teacher] - [adminaccess|asadmin] [csvformat] [todrive *] [formatjson [quotechar ]] + [adminaccess|asadmin] + [csv|csvformat] [todrive *] [formatjson [quotechar ]] ``` If `role` is not specified, `student` will be used. +You can only invite a co-teacher to be an owner of a course. + By default, classroom invitations are issued by the owner of the course, the `adminaccess` option causes the invitations to be issued by the admin named in `oauth2.txt`. +By default, when an invitation is created, GAM outputs details of the invitation as indented keywords and values. +* `csv|csvformat [todrive *] [formatjson [quotechar ]]` - Output the details in CSV format. + ### Example Suppose you have a CSV file CourseStudent.csv with two columns: Course,Student. @@ -66,11 +71,13 @@ This command will invite all students to their courses in parallel ``` gam redirect stdout ./Invites.out multiprocess redirect stderr stdout multiprocess csv CourseStudent.csv gam user ~Student create classroominvitation role student course ~Course ``` -## Accept classroom invitations -Accept classroom invitations for users. You can only invite a co-teacher to be an owner of a course. +## Accept classroom invitations by user +Accept classroom invitations for users. ``` gam accept classroominvitation (ids )|([courses ] [role all|owner|student|teacher]) ``` +`` must specify users in your domain. + By default, all invitations for the specified users will be accepted. Select specific invitations to accept: @@ -81,11 +88,13 @@ Select courses and accept invitations for those courses. By default, invitations for all roles will be accepted; you can limit the acceptances to invitations of a specific role. -## Delete classroom invitations +## Delete classroom invitations by user Delete classroom invitations for users. ``` gam delete classroominvitation (ids )|([courses ] [role all|owner|student|teacher]) ``` +`` must specify users in your domain. + By default, all invitations for the specified users will be deleted. Select specific invitations to delete: @@ -104,8 +113,23 @@ gam show classroominvitations [role all|owner|student|teacher] gam print classroominvitations [todrive *] [role all|owner|student|teacher] [formatjson [quotechar ]] ``` +`` must specify users in your domain. + By default, invitations for all roles will be displayed; you can limit the display to invitations of a specific role. +## Delete classroom invitations by course +Delete classroom invitations for courses. This command must be used to delete non-domain member invitations. +``` +gam delete classroominvitation courses (ids )|(role all|owner|student|teacher) +``` +Select courses and delete invitations for those courses. +* `courses ` - Specify courses + +Select specific invitations to delete: +* `ids ` - Specify invitation IDs + +Select invitations to delete by role. By default, invitations for all roles will be deleted; you can limit the deletions to invitations of a specific role. + ## Display classroom invitations by course ``` gam show classroominvitations (course|class )*|([teacher ] [student ] [states ]) diff --git a/docs/Find-File-Owner.md b/docs/Find-File-Owner.md index ce218ef3..53983f00 100644 --- a/docs/Find-File-Owner.md +++ b/docs/Find-File-Owner.md @@ -48,10 +48,7 @@ The `quotechar ` option allows you to choose an alternate quote chara ## Display File Ownership for Old files If the above commands fail, you can try to loop through all accounts, however this might take a long time if you are on a large Google Workspace Account. -``` -gam config auto_batch_min 1 redirect csv - multiprocess redirect stderr null multiprocess all users print filelist select id fields id,name,owners.emailaddress norecursion showownedby any -``` -Starting with version 6.07.26, this can be made more efficient by terminating processing after the owner is identified. ``` gam config auto_batch_min 1 multiprocessexit rc=0 redirect csv - multiprocess redirect stderr null multiprocess all users print filelist select id fields id,name,owners.emailaddress norecursion showownedby any +gam config auto_batch_min 1 multiprocessexit rc=0 redirect csv - multiprocess redirect stderr null multiprocess all users print filelist select name fields id,name,owners.emailaddress norecursion showownedby any ``` diff --git a/docs/GamUpdates.md b/docs/GamUpdates.md index 9deada0f..ef091896 100644 --- a/docs/GamUpdates.md +++ b/docs/GamUpdates.md @@ -10,6 +10,26 @@ Add the `-s` option to the end of the above commands to suppress creating the `g See [Downloads](https://github.com/taers232c/GAMADV-XTD3/wiki/Downloads) for Windows or other options, including manual installation +### 6.69.00 + +Added `use_classroom_owner_access` Boolean variable to `gam.cfg` that controls how GAM gets +classroom member information and removes students/teachers. Client access does not provide +complete information about non-domain students/teachers. +* `False` - Use client access; this is the default. Use if you don't have non-domain members in your courses. +* `True` - Use service account access as the classroom owner. An extra API call is required per course to authenticate the owner; this will affect performance + +Added the following command which must be used to delete classroom invitations for non-domain students/teachers. +``` +gam delete classroominvitation courses (ids )|(role all|owner|student|teacher) +``` +You can obtain the classroom invitation IDs with these commands: +``` +gam show classroominvitations (course|class )*|([teacher ] [student ] [states ]) + [role all|owner|student|teacher] [formatjson] +gam print classroominvitations [todrive *] (course|class )*|([teacher ] [student ] [states ]) + [role all|owner|student|teacher] [formatjson [quotechar ]] +``` + ### 6.68.08 Updated `gam print filelist|drivefileacls|shareddriveacls ... oneitemperrow` to print diff --git a/docs/How-to-Upgrade-from-Standard-GAM.md b/docs/How-to-Upgrade-from-Standard-GAM.md index 005f28e2..48372cf4 100644 --- a/docs/How-to-Upgrade-from-Standard-GAM.md +++ b/docs/How-to-Upgrade-from-Standard-GAM.md @@ -334,7 +334,7 @@ writes the credentials into the file oauth2.txt. admin@server:/Users/admin/bin/gamadv-xtd3$ rm -f /Users/admin/GAMConfig/oauth2.txt admin@server:/Users/admin/bin/gamadv-xtd3$ ./gam version WARNING: Config File: /Users/admin/GAMConfig/gam.cfg, Section: DEFAULT, Item: oauth2_txt, Value: /Users/admin/GAMConfig/oauth2.txt, Not Found -GAMADV-XTD3 6.68.08 - https://github.com/taers232c/GAMADV-XTD3 - pythonsource +GAMADV-XTD3 6.69.00 - https://github.com/taers232c/GAMADV-XTD3 - pythonsource Ross Scroggs Python 3.12.2 64-bit final MacOS Sonoma 14.2.1 x86_64 @@ -1002,7 +1002,7 @@ writes the credentials into the file oauth2.txt. C:\GAMADV-XTD3>del C:\GAMConfig\oauth2.txt C:\GAMADV-XTD3>gam version WARNING: Config File: C:\GAMConfig\gam.cfg, Section: DEFAULT, Item: oauth2_txt, Value: C:\GAMConfig\oauth2.txt, Not Found -GAMADV-XTD3 6.68.08 - https://github.com/taers232c/GAMADV-XTD3 - pythonsource +GAMADV-XTD3 6.69.00 - https://github.com/taers232c/GAMADV-XTD3 - pythonsource Ross Scroggs Python 3.12.2 64-bit final Windows-10-10.0.17134 AMD64 diff --git a/docs/Other-Resources.md b/docs/Other-Resources.md index 64bc0c91..61f215f5 100644 --- a/docs/Other-Resources.md +++ b/docs/Other-Resources.md @@ -13,3 +13,4 @@ Thank you. * Goldy Arora - https://www.goldyarora.com/license-notifier/ * Paul Ogier (Taming.Tech) - GAMADV-XTD3 Tutorials https://www.youtube.com/watch?v=g9LDeyXQNLI&list=PL_dLiK09pJVhKJxZHNk9CHK0q5hkZ856w * Paul Ogier (Taming.Tech) - GAMADV-XTD3 Course on Udemy https://taming.tech/GAMCourse +* Paul Ogier (Taming.Tech) - https://taming.tech/taming-gam-a-practical-guide-to-gam-and-gamadv-xtd3/ \ No newline at end of file diff --git a/docs/Version-and-Help.md b/docs/Version-and-Help.md index b7fabaaf..99854cb1 100644 --- a/docs/Version-and-Help.md +++ b/docs/Version-and-Help.md @@ -3,7 +3,7 @@ Print the current version of Gam with details ``` gam version -GAMADV-XTD3 6.68.08 - https://github.com/taers232c/GAMADV-XTD3 - pythonsource +GAMADV-XTD3 6.69.00 - https://github.com/taers232c/GAMADV-XTD3 - pythonsource Ross Scroggs Python 3.12.2 64-bit final MacOS Sonoma 14.2.1 x86_64 @@ -15,7 +15,7 @@ Time: 2023-06-02T21:10:00-07:00 Print the current version of Gam with details and time offset information ``` gam version timeoffset -GAMADV-XTD3 6.68.08 - https://github.com/taers232c/GAMADV-XTD3 - pythonsource +GAMADV-XTD3 6.69.00 - https://github.com/taers232c/GAMADV-XTD3 - pythonsource Ross Scroggs Python 3.12.2 64-bit final MacOS Sonoma 14.2.1 x86_64 @@ -27,7 +27,7 @@ Your system time differs from www.googleapis.com by less than 1 second Print the current version of Gam with extended details and SSL information ``` gam version extended -GAMADV-XTD3 6.68.08 - https://github.com/taers232c/GAMADV-XTD3 - pythonsource +GAMADV-XTD3 6.69.00 - https://github.com/taers232c/GAMADV-XTD3 - pythonsource Ross Scroggs Python 3.12.2 64-bit final MacOS Sonoma 14.2.1 x86_64 @@ -64,7 +64,7 @@ MacOS High Sierra 10.13.6 x86_64 Path: /Users/Admin/bin/gamadv-xtd3 Version Check: Current: 5.35.08 - Latest: 6.68.08 + Latest: 6.69.00 echo $? 1 ``` @@ -72,7 +72,7 @@ echo $? Print the current version number without details ``` gam version simple -6.68.08 +6.69.00 ``` In Linux/MacOS you can do: ``` @@ -82,7 +82,7 @@ echo $VER Print the current version of Gam and address of this Wiki ``` gam help -GAM 6.68.08 - https://github.com/taers232c/GAMADV-XTD3 +GAM 6.69.00 - https://github.com/taers232c/GAMADV-XTD3 Ross Scroggs Python 3.12.2 64-bit final MacOS Sonoma 14.2.1 x86_64 diff --git a/docs/gam.cfg.md b/docs/gam.cfg.md index 708205b0..26a44e9c 100644 --- a/docs/gam.cfg.md +++ b/docs/gam.cfg.md @@ -150,7 +150,7 @@ csv_input_column_delimiter Default: ',' csv_input_no_escape_char When reading a CSV file, should `\` be ignored as an escape character. - Set this to False if the input file data was written using `\` as an escape character. + Set this to False if the input file data was written using `\` as an escape character. Default: True csv_input_quote_char A one-character string used to quote fields containing special characters, @@ -220,7 +220,7 @@ csv_output_line_terminator Default: lf csv_output_no_escape_char When writing a CSV file, should `\` be ignored as an escape character. - Set this to True if the output file data is to be read by a non-Python program. + Set this to True if the output file data is to be read by a non-Python program. Default: False csv_output_quote_char A one-character string used to quote fields containing special characters, @@ -420,23 +420,23 @@ print_agu_domains gam print groups gam print|show group-members gam print users - This allows predefining the list of domains so they don't have to be specified in each command. + This allows predefining the list of domains so they don't have to be specified in each command. Default: Blank print_cros_ous A comma separated list of org unit that are used in these commands: gam print cros gam print crosactivity - This allows predefining the list of org units so they don't have to be specified in each command. + This allows predefining the list of org units so they don't have to be specified in each command. Default: Blank print_cros_ous_and_children A comma separated list of org unit names that are used in these commands: gam print cros gam print crosactivity - This allows predefining the list of org units so they don't have to be specified in each command. + This allows predefining the list of org units so they don't have to be specified in each command. Default: Blank process_wait_limit - When processing batch/CSV files, how long (in seconds) GAM should wait for all batch|csv processes to complete - after all have been started. If the limit is reached, GAM terminates any remaining processes. + When processing batch/CSV files, how long (in seconds) GAM should wait for all batch|csv processes to complete + after all have been started. If the limit is reached, GAM terminates any remaining processes. Default: 0: no limit Range: 0 - Unlimited quick_cros_move @@ -573,6 +573,13 @@ update_cros_ou_with_id Set to true if you are getting the following error: `400: invalidInput - Invalid Input: Inconsistent Orgunit id and path in request` Default: False +use_classroom_owner_access + How is classroom member information obtained and how are classroom members deleted. + Client access does not provide complete information about non-domain students/teachers. + When False, GAM uses client access to get classroom member information and to delete members + When True, GAM uses service account access as the classroom owner. + An extra API call is required per course to authenticate the owner + Default: False use_projectid_as_name When False, new projects have a default project name of "GAM Project" and a default app name of "GAM". diff --git a/src/GamCommands.txt b/src/GamCommands.txt index 26b5ca0f..fe1b0697 100644 --- a/src/GamCommands.txt +++ b/src/GamCommands.txt @@ -2876,7 +2876,7 @@ gam course delete topic gam course create|add teachers [makefirstteacherowner] gam course create|add students -gam course delete|remove teachers|students +gam course delete|remove teachers|students [owneraccess] gam course clear teachers|students gam course sync teachers [addonly|removeonly] [makefirstteacherowner] gam course sync students [addonly|removeonly] @@ -2889,15 +2889,17 @@ gam courses delete topic gam courses create|add teachers [makefirstteacherowner] gam courses create|add students -gam courses delete|remove teachers|students +gam courses delete|remove teachers|students [owneraccess] gam courses clear teachers|students gam courses sync teachers [addonly|removeonly] [makefirstteacherowner] gam courses sync students [addonly|removeonly] -gam info course [owneremail] [alias|aliases] [show all|students|teachers] [countsonly] +gam info course [owneraccess] + [owneremail] [alias|aliases] [show all|students|teachers] [countsonly] [fields ] [skipfields ] [formatjson] -gam info courses [owneremail] [alias|aliases] [show all|students|teachers] [countsonly] +gam info courses [owneraccess] + [owneremail] [alias|aliases] [show all|students|teachers] [countsonly] [fields ] [skipfields ] [formatjson] gam print courses [todrive *] @@ -3041,8 +3043,8 @@ gam print course-works [todrive *] # Classroom - Invitations gam create classroominvitation courses [role owner|student|teacher] - [adminaccess|asadmin] [csv|csvformat] [todrive *] - [formatjson [quotechar ]] + [adminaccess|asadmin] + [csv|csvformat] [todrive *] [formatjson [quotechar ]] gam accept classroominvitation (ids )|([courses ] [role all|owner|student|teacher]) gam delete classroominvitation (ids )|([courses ] [role all|owner|student|teacher]) gam show classroominvitations [role all|owner|student|teacher] @@ -3050,6 +3052,7 @@ gam show classroominvitations [role all|owner|student|teacher] gam print classroominvitations [todrive *] [role all|owner|student|teacher] [formatjson [quotechar ]] +gam delete classroominvitation courses (ids )|(role all|owner|student|teacher) gam show classroominvitations (course|class )*|([teacher ] [student ] [states ]) [role all|owner|student|teacher] [formatjson] diff --git a/src/GamUpdate.txt b/src/GamUpdate.txt index 83b81e01..746c259d 100644 --- a/src/GamUpdate.txt +++ b/src/GamUpdate.txt @@ -2,6 +2,26 @@ Merged GAM-Team version +6.69.00 + +Added `use_classroom_owner_access` Boolean variable to `gam.cfg` that controls how GAM gets +classroom member information and removes students/teachers. Client access does not provide +complete information about non-domain students/teachers. +* `False` - Use client access; this is the default. Use if you don't have non-domain members in your courses. +* `True` - Use service account access as the classroom owner. An extra API call is required per course to authenticate the owner; this will affect performance + +Added the following command which must be used to delete classroom invitations for non-domain students/teachers. +``` +gam delete classroominvitation courses (ids )|(role all|owner|student|teacher) +``` +You can obtain the classroom invitation IDs with these commands: +``` +gam show classroominvitations (course|class )*|([teacher ] [student ] [states ]) + [role all|owner|student|teacher] [formatjson] +gam print classroominvitations [todrive *] (course|class )*|([teacher ] [student ] [states ]) + [role all|owner|student|teacher] [formatjson [quotechar ]] +``` + 6.68.08 Updated `gam print filelist|drivefileacls|shareddriveacls ... oneitemperrow` to print diff --git a/src/gam/__init__.py b/src/gam/__init__.py index 7f591348..120f9615 100755 --- a/src/gam/__init__.py +++ b/src/gam/__init__.py @@ -3395,7 +3395,7 @@ def SetGlobalVariables(): def _getCfgHeaderFilter(sectionName, itemName): value = GM.Globals[GM.PARSER].get(sectionName, itemName) headerFilters = [] - if not value: + if not value or (len(value) == 2 and _stringInQuotes(value)): return headerFilters splitStatus, filters = shlexSplitListStatus(value) if splitStatus: @@ -3411,7 +3411,7 @@ def SetGlobalVariables(): def _getCfgHeaderForce(sectionName, itemName): value = GM.Globals[GM.PARSER].get(sectionName, itemName) headerForce = [] - if not value: + if not value or (len(value) == 2 and _stringInQuotes(value)): return headerForce splitStatus, headerForce = shlexSplitListStatus(value) if not splitStatus: @@ -6338,15 +6338,15 @@ def getItemsToModify(entityType, entity, memberRoles=None, isSuspended=None, isA elif entityType in {Cmd.ENTITY_COURSEPARTICIPANTS, Cmd.ENTITY_TEACHERS, Cmd.ENTITY_STUDENTS}: croom = buildGAPIObject(API.CLASSROOM) if not noListConversion: - courses = convertEntityToList(entity) + courseIdList = convertEntityToList(entity) else: - courses = [entity] - for course in courses: - courseId = addCourseIdScope(course) + courseIdList = [entity] + _, _, coursesInfo = _getCoursesOwnerInfo(croom, courseIdList, GC.Values[GC.USE_COURSE_OWNER_ACCESS]) + for courseId, courseInfo in coursesInfo.items(): try: if entityType in {Cmd.ENTITY_COURSEPARTICIPANTS, Cmd.ENTITY_TEACHERS}: printGettingAllEntityItemsForWhom(Ent.TEACHER, removeCourseIdScope(courseId), entityType=Ent.COURSE) - result = callGAPIpages(croom.courses().teachers(), 'list', 'teachers', + result = callGAPIpages(courseInfo['croom'].courses().teachers(), 'list', 'teachers', pageMessage=getPageMessageForWhom(), throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.BAD_REQUEST, GAPI.SERVICE_NOT_AVAILABLE], retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS, @@ -6359,7 +6359,7 @@ def getItemsToModify(entityType, entity, memberRoles=None, isSuspended=None, isA entityList.append(email) if entityType in {Cmd.ENTITY_COURSEPARTICIPANTS, Cmd.ENTITY_STUDENTS}: printGettingAllEntityItemsForWhom(Ent.STUDENT, removeCourseIdScope(courseId), entityType=Ent.COURSE) - result = callGAPIpages(croom.courses().students(), 'list', 'students', + result = callGAPIpages(courseInfo['croom'].courses().students(), 'list', 'students', pageMessage=getPageMessageForWhom(), throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.BAD_REQUEST, GAPI.SERVICE_NOT_AVAILABLE], retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS, @@ -17979,7 +17979,7 @@ def doPrintAliases(): except (GAPI.invalidOrgunit, GAPI.invalidInput): entityActionFailedWarning([Ent.ALIAS, None], invalidQuery(query)) continue - except GAPI.domainNotFound as e : + except GAPI.domainNotFound as e: entityActionFailedWarning([Ent.ALIAS, None, Ent.DOMAIN, kwargs['domain']], str(e)) continue except (GAPI.resourceNotFound, GAPI.forbidden, GAPI.badRequest): @@ -18008,13 +18008,16 @@ def doPrintAliases(): try: entityList = callGAPIpages(cd.groups(), 'list', 'groups', pageMessage=getPageMessage(showFirstLastItems=True), messageAttribute='email', - throwReasons=GAPI.GROUP_LIST_THROW_REASONS, + throwReasons=GAPI.GROUP_LIST_USERKEY_THROW_REASONS, retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS, query=query, orderBy='email', fields=f'nextPageToken,groups({",".join(groupFields)})', **kwargs) for group in entityList: writeAliases(group, group['email'], 'Group') - except GAPI.domainNotFound as e : + except (GAPI.invalidMember, GAPI.invalidInput) as e: + if not invalidMember(query): + entityActionFailedExit([Ent.GROUP, None], str(e)) + except GAPI.domainNotFound as e: entityActionFailedWarning([Ent.ALIAS, None, Ent.DOMAIN, kwargs['domain']], str(e)) continue except (GAPI.resourceNotFound, GAPI.forbidden, GAPI.badRequest): @@ -41517,7 +41520,7 @@ def doCreateUser(): throwReasons=[GAPI.DUPLICATE, GAPI.DOMAIN_NOT_FOUND, GAPI.DOMAIN_CANNOT_USE_APIS, GAPI.FORBIDDEN, GAPI.INVALID, GAPI.INVALID_INPUT, GAPI.INVALID_PARAMETER, - GAPI.INVALID_ORGUNIT, GAPI.INVALID_SCHEMA_VALUE], + GAPI.INVALID_ORGUNIT, GAPI.INVALID_SCHEMA_VALUE, GAPI.CONDITION_NOT_MET], body=body, fields=fields, resolveConflictAccount=resolveConflictAccount) @@ -41536,7 +41539,7 @@ def doCreateUser(): except GAPI.invalidOrgunit: entityActionFailedExit([Ent.USER, user], Msg.INVALID_ORGUNIT) except (GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, - GAPI.invalid, GAPI.invalidInput, GAPI.invalidParameter) as e: + GAPI.invalid, GAPI.invalidInput, GAPI.invalidParameter, GAPI.conditionNotMet) as e: entityActionFailedExit([Ent.USER, user], str(e)) if PwdOpts.filename and PwdOpts.password: writeFile(PwdOpts.filename, f'{user},{PwdOpts.password}\n', mode='a', continueOnError=True) @@ -41698,7 +41701,7 @@ def updateUsers(entityList): result = callGAPI(cd.users(), 'insert', throwReasons=[GAPI.DUPLICATE, GAPI.DOMAIN_NOT_FOUND, GAPI.FORBIDDEN, GAPI.INVALID, GAPI.INVALID_INPUT, GAPI.INVALID_PARAMETER, - GAPI.INVALID_ORGUNIT, GAPI.INVALID_SCHEMA_VALUE], + GAPI.INVALID_ORGUNIT, GAPI.INVALID_SCHEMA_VALUE, GAPI.CONDITION_NOT_MET], body=body, fields=fields, resolveConflictAccount=resolveConflictAccount) @@ -41730,7 +41733,7 @@ def updateUsers(entityList): entityActionFailedWarning([Ent.USER, user], Msg.INVALID_ORGUNIT, i, count) except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.badRequest, GAPI.invalid, GAPI.invalidInput, GAPI.invalidParameter, GAPI.insufficientArchivedUserLicenses, - GAPI.conflict, GAPI.badRequest, GAPI.backendError, GAPI.systemError) as e: + GAPI.conflict, GAPI.badRequest, GAPI.backendError, GAPI.systemError, GAPI.conditionNotMet) as e: entityActionFailedWarning([Ent.USER, user], str(e), i, count) # gam update users ... @@ -44212,6 +44215,8 @@ class CourseAttributes(): def __init__(self, croom, updateMode): self.croom = croom + self.ocroom = croom + self.tcroom = None self.updateMode = updateMode self.body = {} self.courseId = None @@ -44371,15 +44376,20 @@ class CourseAttributes(): missingArgumentExit('copyfrom ') else: return True +# ocroom - copyfrom course owner + if self.announcementStates or self.materialStates or self.workStates or self.copyTopics or self.members != 'none': + _, self.ocroom = buildGAPIServiceObject(API.CLASSROOM, f'uid:{self.ownerId}') + if self.ocroom is None: + return False if self.members != 'none': - _, self.teachers, self.students = _getCourseAliasesMembers(self.croom, self.croom, self.courseId, {'members': self.members}, + _, self.teachers, self.students = _getCourseAliasesMembers(self.croom, self.ocroom, self.courseId, {'members': self.members}, 'nextPageToken,teachers(profile(emailAddress,id))', 'nextPageToken,students(profile(emailAddress))') if self.announcementStates: printGettingAllEntityItemsForWhom(Ent.COURSE_ANNOUNCEMENT_ID, Ent.TypeName(Ent.COURSE, self.courseId), 0, 0, _gettingCourseEntityQuery(Ent.COURSE_ANNOUNCEMENT_STATE, self.announcementStates)) try: - self.courseAnnouncements = callGAPIpages(self.croom.courses().announcements(), 'list', 'announcements', + self.courseAnnouncements = callGAPIpages(self.ocroom.courses().announcements(), 'list', 'announcements', pageMessage=getPageMessageForWhom(), throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.SERVICE_NOT_AVAILABLE], retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS, @@ -44396,7 +44406,7 @@ class CourseAttributes(): printGettingAllEntityItemsForWhom(Ent.COURSE_MATERIAL_ID, Ent.TypeName(Ent.COURSE, self.courseId), 0, 0, _gettingCourseEntityQuery(Ent.COURSE_MATERIAL_STATE, self.materialStates)) try: - self.courseMaterials = callGAPIpages(self.croom.courses().courseWorkMaterials(), 'list', 'courseWorkMaterial', + self.courseMaterials = callGAPIpages(self.ocroom.courses().courseWorkMaterials(), 'list', 'courseWorkMaterial', pageMessage=getPageMessageForWhom(), throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.SERVICE_NOT_AVAILABLE], retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS, @@ -44417,7 +44427,7 @@ class CourseAttributes(): printGettingAllEntityItemsForWhom(Ent.COURSE_WORK_ID, Ent.TypeName(Ent.COURSE, self.courseId), 0, 0, _gettingCourseEntityQuery(Ent.COURSE_WORK_STATE, self.workStates)) try: - self.courseWorks = callGAPIpages(self.croom.courses().courseWork(), 'list', 'courseWork', + self.courseWorks = callGAPIpages(self.ocroom.courses().courseWork(), 'list', 'courseWork', pageMessage=getPageMessageForWhom(), throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.SERVICE_NOT_AVAILABLE], retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS, @@ -44441,7 +44451,7 @@ class CourseAttributes(): if self.copyTopics: printGettingAllEntityItemsForWhom(Ent.COURSE_TOPIC, Ent.TypeName(Ent.COURSE, self.courseId), 0, 0) try: - courseTopics = callGAPIpages(self.croom.courses().topics(), 'list', 'topic', + courseTopics = callGAPIpages(self.ocroom.courses().topics(), 'list', 'topic', pageMessage=getPageMessageForWhom(), throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.SERVICE_NOT_AVAILABLE], retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS, @@ -44517,14 +44527,16 @@ class CourseAttributes(): newCourseId = newCourse['id'] ownerId = newCourse['ownerId'] teacherFolderId = newCourse['teacherFolder']['id'] +# tcroom - new/update course owner if self.announcementStates or self.materialStates or self.workStates or self.copyTopics: - _, tcroom = buildGAPIServiceObject(API.CLASSROOM, f'uid:{ownerId}') - if tcroom is None: + _, self.tcroom = buildGAPIServiceObject(API.CLASSROOM, f'uid:{ownerId}') + if self.tcroom is None: return if (self.announcementStates or self.materialStates or self.workStates) and self.copyMaterialsFiles: _, tdrive = buildGAPIServiceObject(API.DRIVE3, f'uid:{ownerId}') if tdrive is None: return +# Adds are done with domain admin if self.members in {'all', 'students'}: addParticipants = [student['profile']['emailAddress'] for student in self.students if 'emailAddress' in student['profile']] _batchAddItemsToCourse(self.croom, newCourseId, i, count, addParticipants, Ent.STUDENT) @@ -44533,7 +44545,7 @@ class CourseAttributes(): _batchAddItemsToCourse(self.croom, newCourseId, i, count, addParticipants, Ent.TEACHER) if self.copyTopics: try: - newCourseTopics = callGAPIpages(self.croom.courses().topics(), 'list', 'topic', + newCourseTopics = callGAPIpages(self.tcroom.courses().topics(), 'list', 'topic', throwReasons=GAPI.COURSE_ACCESS_THROW_REASONS+[GAPI.FAILED_PRECONDITION, GAPI.SERVICE_NOT_AVAILABLE], retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS, courseId=newCourseId, fields='nextPageToken,topic(topicId,name)', @@ -44556,7 +44568,7 @@ class CourseAttributes(): [Ent.COURSE, self.courseId], Msg.DUPLICATE, j, jcount) continue try: - result = callGAPI(tcroom.courses().topics(), 'create', + result = callGAPI(self.tcroom.courses().topics(), 'create', throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.FAILED_PRECONDITION, GAPI.INVALID_ARGUMENT, GAPI.SERVICE_NOT_AVAILABLE], retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS, courseId=newCourseId, body={'name': topicName}, fields='topicId') @@ -44583,7 +44595,7 @@ class CourseAttributes(): if self.copyMaterialsFiles: self.CopyMaterials(tdrive, newCourseId, body, Ent.COURSE_ANNOUNCEMENT_ID, courseAnnouncementId, teacherFolderId) try: - result = callGAPI(tcroom.courses().announcements(), 'create', + result = callGAPI(self.tcroom.courses().announcements(), 'create', throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.FORBIDDEN, GAPI.BAD_REQUEST, GAPI.FAILED_PRECONDITION, GAPI.BACKEND_ERROR, GAPI.INTERNAL_ERROR, GAPI.SERVICE_NOT_AVAILABLE], retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS, @@ -44619,7 +44631,7 @@ class CourseAttributes(): if newTopicId: body['topicId'] = newTopicId try: - result = callGAPI(tcroom.courses().courseWorkMaterials(), 'create', + result = callGAPI(self.tcroom.courses().courseWorkMaterials(), 'create', throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.FORBIDDEN, GAPI.BAD_REQUEST, GAPI.FAILED_PRECONDITION, GAPI.BACKEND_ERROR, GAPI.INTERNAL_ERROR, GAPI.SERVICE_NOT_AVAILABLE], retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS, @@ -44658,7 +44670,7 @@ class CourseAttributes(): body.pop('dueDate', None) body.pop('dueTime', None) try: - result = callGAPI(tcroom.courses().courseWork(), 'create', + result = callGAPI(self.tcroom.courses().courseWork(), 'create', bailOnInternalError=True, throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.FORBIDDEN, GAPI.BAD_REQUEST, GAPI.FAILED_PRECONDITION, GAPI.BACKEND_ERROR, @@ -45003,6 +45015,30 @@ def _convertCourseUserIdToEmail(croom, userId, emails, entityValueList, i, count emails[userId] = userEmail return userEmail +def _getCoursesOwnerInfo(croom, courseIds, useOwnerAccess): + coursesInfo = {} + for courseId in courseIds: + courseId = addCourseIdScope(courseId) + if courseId not in coursesInfo: + try: + course = callGAPI(croom.courses(), 'get', + throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE], + retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS, + id=courseId, fields='name,ownerId') + if useOwnerAccess: + _, ocroom = buildGAPIServiceObject(API.CLASSROOM, f'uid:{course["ownerId"]}') + else: + ocroom = croom + if ocroom is not None: + coursesInfo[courseId] = {'name': course['name'], 'croom': ocroom} + except GAPI.notFound: + entityDoesNotExistWarning(Ent.COURSE, courseId) + except (GAPI.permissionDenied, GAPI.serviceNotAvailable) as e: + entityActionFailedWarning([Ent.COURSE, courseId], str(e)) + except GAPI.forbidden: + ClientAPIAccessDeniedExit() + return 0, len(coursesInfo), coursesInfo + def _getCourseAliasesMembers(croom, ocroom, courseId, courseShowProperties, teachersFields, studentsFields, showGettings=False, i=0, count=0): aliases = [] teachers = [] @@ -45059,7 +45095,7 @@ def _doInfoCourses(courseIdList): courseShowProperties = _initCourseShowProperties() courseShowProperties['ownerEmail'] = True ownerEmails = {} - useOwnerAccess = False + useOwnerAccess = GC.Values[GC.USE_COURSE_OWNER_ACCESS] FJQC = FormatJSONQuoteChar() while Cmd.ArgumentsRemaining(): myarg = getArgument() @@ -45069,8 +45105,6 @@ def _doInfoCourses(courseIdList): useOwnerAccess = True else: FJQC.GetFormatJSON(myarg) - coursesInfo = {} - _getCoursesOwnerInfo(croom, courseIdList, coursesInfo, not useOwnerAccess) fields = _setCourseFields(courseShowProperties, False) if courseShowProperties['members'] != 'none': if courseShowProperties['countsOnly']: @@ -45081,14 +45115,9 @@ def _doInfoCourses(courseIdList): studentsFields = 'nextPageToken,students(profile)' else: teachersFields = studentsFields = None - i = 0 - count = len(courseIdList) - for courseId in courseIdList: + i, count, coursesInfo = _getCoursesOwnerInfo(croom, courseIdList, useOwnerAccess) + for courseId, courseInfo in coursesInfo.items(): i += 1 - courseId = addCourseIdScope(courseId) - courseInfo = coursesInfo[courseId] - if not courseInfo: - continue try: course = callGAPI(croom.courses(), 'get', throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE], @@ -45159,12 +45188,14 @@ def _doInfoCourses(courseIdList): except GAPI.forbidden: ClientAPIAccessDeniedExit() -# gam info courses [owneremail] [alias|aliases] [show none|all|students|teachers] [countsonly] +# gam info courses [owneraccess] +# [owneremail] [alias|aliases] [show none|all|students|teachers] [countsonly] # [fields ] [skipfields ] [formatjson] def doInfoCourses(): _doInfoCourses(getEntityList(Cmd.OB_COURSE_ENTITY, shlexSplit=True)) -# gam info course [owneremail] [alias|aliases] [show none|all|students|teachers] [countsonly] +# gam info course [owneraccess] +# [owneremail] [alias|aliases] [show none|all|students|teachers] [countsonly] # [fields ] [skipfields ] [formatjson] def doInfoCourse(): _doInfoCourses(getStringReturnInList(Cmd.OB_COURSE_ID)) @@ -45322,6 +45353,7 @@ def doPrintCourses(): ownerEmails = {} delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER] showItemCountOnly = False + useOwnerAccess = GC.Values[GC.USE_COURSE_OWNER_ACCESS] while Cmd.ArgumentsRemaining(): myarg = getArgument() if myarg == 'todrive': @@ -45342,7 +45374,7 @@ def doPrintCourses(): if applyCourseItemFilter: if courseShowProperties['fields']: courseShowProperties['fields'].append(courseItemFilter['timefilter']) - coursesInfo = _getCoursesInfo(croom, courseSelectionParameters, courseShowProperties) + coursesInfo = _getCoursesInfo(croom, courseSelectionParameters, courseShowProperties, useOwnerAccess) if coursesInfo is None: if showItemCountOnly: writeStdout('0\n') @@ -45381,6 +45413,12 @@ def doPrintCourses(): for field in courseShowProperties['skips']: course.pop(field, None) courseId = course['id'] + if useOwnerAccess: + _, ocroom = buildGAPIServiceObject(API.CLASSROOM, f'uid:{course["ownerId"]}') + if not ocroom: + continue + else: + ocroom = croom if courseShowProperties['ownerEmail']: course['ownerEmail'] = _convertCourseUserIdToEmail(croom, course['ownerId'], ownerEmails, [Ent.COURSE, courseId, Ent.OWNER_ID, course['ownerId']], i, count) @@ -45389,7 +45427,7 @@ def doPrintCourses(): if showItemCountOnly: itemCount += 1 continue - aliases, teachers, students = _getCourseAliasesMembers(croom, croom, courseId, courseShowProperties, teachersFields, studentsFields, True, i, count) + aliases, teachers, students = _getCourseAliasesMembers(croom, ocroom, courseId, courseShowProperties, teachersFields, studentsFields, True, i, count) if courseShowProperties['aliases']: if not courseShowProperties['aliasesInColumns']: course['Aliases'] = delimiter.join([removeCourseAliasScope(alias['alias']) for alias in aliases]) @@ -45614,7 +45652,6 @@ def doPrintCourseTopics(): courseId = course['id'] if courseTopicIdsLists: courseTopicIds = courseTopicIdsLists[courseId] - if not courseTopicIds: fields = getItemFieldsFromFieldsList('topic', fieldsList) printGettingAllEntityItemsForWhom(Ent.COURSE_TOPIC, Ent.TypeName(Ent.COURSE, courseId), i, count) @@ -46098,6 +46135,7 @@ def doPrintCourseParticipants(): courseShowProperties = _initCourseShowProperties(['name']) courseShowProperties['members'] = 'all' showItemCountOnly = False + useOwnerAccess = GC.Values[GC.USE_COURSE_OWNER_ACCESS] while Cmd.ArgumentsRemaining(): myarg = getArgument() if myarg == 'todrive': @@ -46110,7 +46148,7 @@ def doPrintCourseParticipants(): showItemCountOnly = True else: FJQC.GetFormatJSONQuoteChar(myarg, False) - coursesInfo = _getCoursesInfo(croom, courseSelectionParameters, courseShowProperties) + coursesInfo = _getCoursesInfo(croom, courseSelectionParameters, courseShowProperties, useOwnerAccess) if coursesInfo is None: if showItemCountOnly: writeStdout('0\n') @@ -46132,7 +46170,13 @@ def doPrintCourseParticipants(): for course in coursesInfo: i += 1 courseId = course['id'] - _, teachers, students = _getCourseAliasesMembers(croom, croom, courseId, courseShowProperties, teachersFields, studentsFields, True, i, count) + if useOwnerAccess: + _, ocroom = buildGAPIServiceObject(API.CLASSROOM, f'uid:{course["ownerId"]}') + if not ocroom: + continue + else: + ocroom = croom + _, teachers, students = _getCourseAliasesMembers(croom, ocroom, courseId, courseShowProperties, teachersFields, studentsFields, True, i, count) if showItemCountOnly: if courseShowProperties['members'] != 'students': itemCount += len(teachers) @@ -46319,29 +46363,6 @@ def _batchRemoveItemsFromCourse(croom, courseId, i, count, removeParticipants, r dbatch.execute() Ind.Decrement() -def _getCoursesOwnerInfo(croom, courseIds, coursesInfo, useAdminAccess): - for courseId in courseIds: - courseId = addCourseIdScope(courseId) - if courseId not in coursesInfo: - coursesInfo[courseId] = {} - try: - info = callGAPI(croom.courses(), 'get', - throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE], - retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS, - id=courseId, fields='name,ownerId') - if not useAdminAccess: - _, ocroom = buildGAPIServiceObject(API.CLASSROOM, f'uid:{info["ownerId"]}') - else: - ocroom = croom - if ocroom is not None: - coursesInfo[courseId] = {'name': info['name'], 'croom': ocroom} - except GAPI.notFound: - entityDoesNotExistWarning(Ent.COURSE, courseId) - except (GAPI.permissionDenied, GAPI.serviceNotAvailable) as e: - entityActionFailedWarning([Ent.COURSE, courseId], str(e)) - except GAPI.forbidden: - ClientAPIAccessDeniedExit() - def _updateCourseOwner(croom, courseId, owner, i, count): action = Act.Get() Act.Set(Act.UPDATE_OWNER) @@ -46398,7 +46419,6 @@ def doCourseAddItems(courseIdList, getEntityListArg): makeFirstTeacherOwner = checkArgumentPresent(['makefirstteacherowner']) else: makeFirstTeacherOwner = False - coursesInfo = {} if not getEntityListArg: if role in {Ent.STUDENT, Ent.TEACHER}: addItems = getStringReturnInList(Cmd.OB_EMAIL_ADDRESS) @@ -46422,22 +46442,17 @@ def doCourseAddItems(courseIdList, getEntityListArg): if makeFirstTeacherOwner and addItems: firstTeacher = normalizeEmailAddressOrUID(addItems[0]) checkForExtraneousArguments() - _getCoursesOwnerInfo(croom, courseIdList, coursesInfo, role != Ent.COURSE_TOPIC) - i = 0 - count = len(courseIdList) - for courseId in courseIdList: + i, count, coursesInfo = _getCoursesOwnerInfo(croom, courseIdList, role == Ent.COURSE_TOPIC) + for courseId, courseInfo in coursesInfo.items(): i += 1 if courseParticipantLists: addItems = courseParticipantLists[courseId] firstTeacher = None if makeFirstTeacherOwner and addItems: firstTeacher = normalizeEmailAddressOrUID(addItems[0]) - courseId = addCourseIdScope(courseId) - courseInfo = coursesInfo[courseId] - if courseInfo: - _batchAddItemsToCourse(courseInfo['croom'], courseId, i, count, addItems, role) - if makeFirstTeacherOwner and firstTeacher: - _updateCourseOwner(courseInfo['croom'], courseId, firstTeacher, i, count) + _batchAddItemsToCourse(courseInfo['croom'], courseId, i, count, addItems, role) + if makeFirstTeacherOwner and firstTeacher: + _updateCourseOwner(courseInfo['croom'], courseId, firstTeacher, i, count) # gam courses remove alias # gam course remove alias @@ -46448,10 +46463,11 @@ def doCourseAddItems(courseIdList, getEntityListArg): def doCourseRemoveItems(courseIdList, getEntityListArg): croom = buildGAPIObject(API.CLASSROOM) role = getChoice(ADD_REMOVE_PARTICIPANT_TYPES_MAP, mapChoice=True) - coursesInfo = {} if not getEntityListArg: if role in {Ent.STUDENT, Ent.TEACHER}: - useOwnerAccess = checkArgumentPresent(OWNER_ACCESS_OPTIONS) + useOwnerAccess = GC.Values[GC.USE_COURSE_OWNER_ACCESS] + if checkArgumentPresent(OWNER_ACCESS_OPTIONS): + useOwnerAccess = True removeItems = getStringReturnInList(Cmd.OB_EMAIL_ADDRESS) elif role == Ent.COURSE_ALIAS: useOwnerAccess = False @@ -46473,17 +46489,12 @@ def doCourseRemoveItems(courseIdList, getEntityListArg): removeItems = getEntityList(Cmd.OB_COURSE_TOPIC_ID_ENTITY, shlexSplit=True) courseParticipantLists = removeItems if isinstance(removeItems, dict) else None checkForExtraneousArguments() - _getCoursesOwnerInfo(croom, courseIdList, coursesInfo, not useOwnerAccess) - i = 0 - count = len(courseIdList) - for courseId in courseIdList: + i, count, coursesInfo = _getCoursesOwnerInfo(croom, courseIdList, useOwnerAccess) + for courseId, courseInfo in coursesInfo.items(): i += 1 if courseParticipantLists: removeItems = courseParticipantLists[courseId] - courseId = addCourseIdScope(courseId) - courseInfo = coursesInfo[courseId] - if courseInfo: - _batchRemoveItemsFromCourse(courseInfo['croom'], courseId, i, count, removeItems, role) + _batchRemoveItemsFromCourse(courseInfo['croom'], courseId, i, count, removeItems, role) # gam courses clear teachers|students # gam course clear teacher|student @@ -46491,14 +46502,13 @@ def doCourseClearParticipants(courseIdList, getEntityListArg): croom = buildGAPIObject(API.CLASSROOM) role = getChoice(CLEAR_SYNC_PARTICIPANT_TYPES_MAP, mapChoice=True) checkForExtraneousArguments() - i = 0 - count = len(courseIdList) - for courseId in courseIdList: + i, count, coursesInfo = _getCoursesOwnerInfo(croom, courseIdList, GC.Values[GC.USE_COURSE_OWNER_ACCESS]) + for courseId, courseInfo in coursesInfo.items(): i += 1 removeParticipants = getItemsToModify(PARTICIPANT_EN_MAP[role], courseId, noListConversion=True) if GM.Globals[GM.CLASSROOM_SERVICE_NOT_AVAILABLE]: continue - _batchRemoveItemsFromCourse(croom, courseId, i, count, removeParticipants, role) + _batchRemoveItemsFromCourse(courseInfo['croom'], courseId, i, count, removeParticipants, role) # gam courses sync students [addonly|removeonly] # gam course sync students [addonly|removeonly] @@ -46525,9 +46535,8 @@ def doCourseSyncParticipants(courseIdList, getEntityListArg): syncParticipantsSet.add(normalizeEmailAddressOrUID(user)) if makeFirstTeacherOwner: firstTeacher = normalizeEmailAddressOrUID(syncParticipants[0]) - i = 0 - count = len(courseIdList) - for courseId in courseIdList: + i, count, coursesInfo = _getCoursesOwnerInfo(croom, courseIdList, GC.Values[GC.USE_COURSE_OWNER_ACCESS]) + for courseId, courseInfo in coursesInfo.items(): i += 1 if courseParticipantLists: syncParticipantsSet = set() @@ -46537,21 +46546,18 @@ def doCourseSyncParticipants(courseIdList, getEntityListArg): syncParticipantsSet.add(normalizeEmailAddressOrUID(user)) if makeFirstTeacherOwner: firstTeacher = normalizeEmailAddressOrUID(courseParticipantLists[courseId][0]) - courseInfo = checkCourseExists(croom, courseId, i, count) - if courseInfo: - courseId = courseInfo['id'] - currentParticipantsSet = set() - currentParticipants = getItemsToModify(PARTICIPANT_EN_MAP[role], courseId, noListConversion=True) - if GM.Globals[GM.CLASSROOM_SERVICE_NOT_AVAILABLE]: - continue - for user in currentParticipants: - currentParticipantsSet.add(normalizeEmailAddressOrUID(user)) - if syncOperation != 'removeonly': - _batchAddItemsToCourse(croom, courseId, i, count, list(syncParticipantsSet-currentParticipantsSet), role) - if makeFirstTeacherOwner and firstTeacher: - _updateCourseOwner(croom, courseId, firstTeacher, i, count) - if syncOperation != 'addonly': - _batchRemoveItemsFromCourse(croom, courseId, i, count, list(currentParticipantsSet-syncParticipantsSet), role) + currentParticipantsSet = set() + currentParticipants = getItemsToModify(PARTICIPANT_EN_MAP[role], courseId, noListConversion=True) + if GM.Globals[GM.CLASSROOM_SERVICE_NOT_AVAILABLE]: + continue + for user in currentParticipants: + currentParticipantsSet.add(normalizeEmailAddressOrUID(user)) + if syncOperation != 'removeonly': + _batchAddItemsToCourse(croom, courseId, i, count, list(syncParticipantsSet-currentParticipantsSet), role) + if makeFirstTeacherOwner and firstTeacher: + _updateCourseOwner(croom, courseId, firstTeacher, i, count) + if syncOperation != 'addonly': + _batchRemoveItemsFromCourse(courseInfo['croom'], courseId, i, count, list(currentParticipantsSet-syncParticipantsSet), role) def studentUnknownWarning(studentId, errMsg, i, count): setSysExitRC(SERVICE_NOT_APPLICABLE_RC) @@ -47208,9 +47214,8 @@ def createClassroomInvitations(users): croom = buildGAPIObject(API.CLASSROOM) classroomEmails = {} courseIds = None - coursesInfo = {} role = CLASSROOM_ROLE_STUDENT - useAdminAccess = False + useOwnerAccess = True csvPF = None FJQC = FormatJSONQuoteChar(csvPF) while Cmd.ArgumentsRemaining(): @@ -47225,20 +47230,20 @@ def createClassroomInvitations(users): elif csvPF and myarg == 'todrive': csvPF.GetTodriveParameters() elif myarg in ADMIN_ACCESS_OPTIONS: - useAdminAccess = True + useOwnerAccess = False else: FJQC.GetFormatJSONQuoteChar(myarg, False) if courseIds is None: missingArgumentExit('courses ') if csvPF: if FJQC.formatJSON: - csvPF.SetTitles(['userEmail', 'JSON']) + csvPF.SetJSONTitles(['userEmail', 'JSON']) else: - csvPF.SetTitles(['userId', 'userEmail', 'courseId', 'courseName', 'id', 'role']) - csvPF.SetSortAllTitles() + csvPF.SetTitles(['userEmail', 'courseId', 'courseName', 'id', 'role']) + csvPF.SetSortAllTitles() courseIdsLists = courseIds if isinstance(courseIds, dict) else None if courseIdsLists is None: - _getCoursesOwnerInfo(croom, courseIds, coursesInfo, useAdminAccess) + j, jcount, coursesInfo = _getCoursesOwnerInfo(croom, courseIds, useOwnerAccess) entityType = CLASSROOM_ROLE_ENTITY_MAP[role] i, count, users = getEntityArgument(users) for user in users: @@ -47246,25 +47251,20 @@ def createClassroomInvitations(users): userId = normalizeEmailAddressOrUID(user) userEmail = _getClassroomEmail(croom, classroomEmails, userId, userId) if courseIdsLists: - courseIds = courseIdsLists[user] - _getCoursesOwnerInfo(croom, courseIds, coursesInfo, useAdminAccess) - jcount = len(courseIds) + j, jcount, coursesInfo = _getCoursesOwnerInfo(croom, courseIdsLists[user], useOwnerAccess) if csvPF or not FJQC.formatJSON: entityPerformActionNumItems([Ent.USER, userId], jcount, entityType, i, count) if jcount == 0: continue - j = 0 - for courseId in courseIds: + for courseId, courseInfo in coursesInfo.items(): j += 1 - courseId = addCourseIdScope(courseId) - courseInfo = coursesInfo[courseId] - if not courseInfo: - continue courseNameId = f'{courseInfo["name"]} ({courseId})' try: invitation = callGAPI(courseInfo['croom'].invitations(), 'create', throwReasons=[GAPI.NOT_FOUND, GAPI.FAILED_PRECONDITION, GAPI.ALREADY_EXISTS, GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED], body={'userId': userId, 'courseId': courseId, 'role': role}) + invitation['courseName'] = courseInfo['name'] + invitation['userEmail'] = userEmail if not csvPF: if not FJQC.formatJSON: Ind.Increment() @@ -47274,14 +47274,12 @@ def createClassroomInvitations(users): printLine(json.dumps(cleanJSON(invitation), ensure_ascii=False, sort_keys=True)) else: if not FJQC.formatJSON: - invitation['courseName'] = courseInfo['name'] - invitation['userEmail'] = userEmail csvPF.WriteRow(invitation) else: csvPF.WriteRowNoFilter({'userEmail': userEmail, 'JSON': json.dumps(cleanJSON(invitation), ensure_ascii=False, sort_keys=True)}) - except GAPI.permissionDenied: - entityUnknownWarning(Ent.USER, userId, i, count) + except GAPI.permissionDenied as e: + entityActionFailedWarning([Ent.USER, userId, Ent.COURSE, courseNameId, entityType, None], str(e), j, jcount) break except GAPI.notFound: entityUnknownWarning(Ent.COURSE, courseNameId, j, jcount) @@ -47422,6 +47420,49 @@ def printShowClassroomInvitations(users): if csvPF: csvPF.writeCSVfile('ClassroomInvitations') +# gam delete classroominvitation courses (ids )|(role all|owner|student|teacher) +def doDeleteClassroomInvitations(): + croom = buildGAPIObject(API.CLASSROOM) + courseIdList = invitationIds = None + role = CLASSROOM_ROLE_ALL + while Cmd.ArgumentsRemaining(): + myarg = getArgument() + if myarg in {'course', 'courses', 'class', 'classes'}: + courseIdList = getEntityList(Cmd.OB_COURSE_ENTITY, shlexSplit=True) + elif myarg in {'id', 'ids'}: + invitationIds = getEntityList(Cmd.OB_CLASSROOM_INVITATION_ID_ENTITY) + elif myarg == 'role': + role = getChoice(CLASSROOM_ROLE_MAP, mapChoice=True) + else: + unknownArgumentExit() + if courseIdList is None: + missingArgumentExit('courses ') + entityType = Ent.CLASSROOM_INVITATION + i, count, coursesInfo = _getCoursesOwnerInfo(croom, courseIdList, True) + for courseId, courseInfo in coursesInfo.items(): + i += 1 + courseNameId = f'{courseInfo["name"]} ({courseId})' + if invitationIds is not None: + userInvitationIds = invitationIds + else: + status, userInvitationIds = _getClassroomInvitationIds(courseInfo['croom'], None, [courseId], role, i, count) + if status < 0: + continue + jcount = len(userInvitationIds) + entityPerformActionNumItems([Ent.COURSE, courseNameId], jcount, entityType, i, count) + Ind.Increment() + j = 0 + for invitationId in userInvitationIds: + j += 1 + try: + callGAPI(courseInfo['croom'].invitations(), 'delete', + throwReasons=[GAPI.NOT_FOUND, GAPI.FAILED_PRECONDITION, GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED], + id=invitationId) + entityActionPerformed([Ent.COURSE, courseNameId, entityType, invitationId], j, jcount) + except (GAPI.notFound, GAPI.failedPrecondition, GAPI.forbidden, GAPI.permissionDenied) as e: + entityActionFailedWarning([Ent.COURSE, courseNameId, entityType, invitationId], str(e), j, jcount) + Ind.Decrement() + # gam show classroominvitations (course|class )*|([teacher ] [student ] [states ]) # [role all|owner|student|teacher] [formatjson] # gam print classroominvitations [todrive *] (course|class )*|([teacher ] [student ] [states ]) @@ -51304,6 +51345,11 @@ def _mapDriveRevisionNames(revision): revision['lastModifyingUserName'] = revision['lastModifyingUser']['displayName'] _mapDriveUser(revision['lastModifyingUser']) +DRIVEFILE_BASIC_PERMISSION_FIELDS = [ + 'displayName', 'id', 'emailAddress', 'domain', 'role', 'type', + 'allowFileDiscovery', 'expirationTime', 'deleted', 'permissionDetails' #permissionDetails must be last + ] + DRIVE_FIELDS_CHOICE_MAP = { 'alternatelink': 'webViewLink', 'appdatacontents': 'spaces', @@ -51362,8 +51408,9 @@ DRIVE_FIELDS_CHOICE_MAP = { 'ownernames': 'owners.displayName', 'owners': 'owners', 'parents': 'parents', - 'permissions': 'permissions', + 'permissiondetails': 'permissions.permissionDetails', 'permissionids': 'permissionIds', + 'permissions': 'permissions', 'properties': 'properties', 'quotabytesused': 'quotaBytesUsed', 'quotaused': 'quotaBytesUsed', @@ -51719,7 +51766,7 @@ def showFileInfo(users): showLabels = getChoice(SHOWLABELS_CHOICES) elif myarg == 'showshareddrivepermissions': getPermissionsForSharedDrives = True - permissionsFields = 'nextPageToken,permissions' + permissionsFields = f'nextPageToken,permissions({",".join(DRIVEFILE_BASIC_PERMISSION_FIELDS)})' elif myarg == 'returnidonly': returnIdOnly = True elif DFF.ProcessArgument(myarg): @@ -51727,7 +51774,8 @@ def showFileInfo(users): else: FJQC.GetFormatJSON(myarg) if DFF.fieldsList: - getPermissionsForSharedDrives, permissionsFields = _setGetPermissionsForSharedDrives(DFF.fieldsList) + if not getPermissionsForSharedDrives: + getPermissionsForSharedDrives, permissionsFields = _setGetPermissionsForSharedDrives(DFF.fieldsList) _setSelectionFields() fields = getFieldsFromFieldsList(DFF.fieldsList) showNoParents = 'parents' in DFF.fieldsList @@ -52581,6 +52629,7 @@ DRIVEFILE_ACL_ROLES_MAP = { } DRIVEFILE_ACL_PERMISSION_TYPES = ['anyone', 'domain', 'group', 'user'] # anyone must be first element +DRIVEFILE_ACL_PERMISSION_DETAILS_TYPES = ['file', 'member'] class PermissionMatch(): _PERMISSION_MATCH_ACTION_MAP = {'process': True, 'skip': False} @@ -52667,10 +52716,13 @@ class PermissionMatch(): self.permissionFields.add('expirationTime') elif myarg == 'deleted': deletedLocation = Cmd.Location() - body['deleted'] = getBoolean() + body[myarg] = getBoolean() self.permissionFields.add('deleted') elif myarg == 'inherited': - body['inherited'] = getBoolean() + body[myarg] = getBoolean() + self.permissionFields.add('permissionDetails') + elif myarg == 'permtype': + body['permissionType'] = getChoice(DRIVEFILE_ACL_PERMISSION_DETAILS_TYPES) self.permissionFields.add('permissionDetails') elif myarg in {'em', 'endmatch'}: break @@ -52710,21 +52762,27 @@ class PermissionMatch(): if field in {'type', 'role'}: if permission.get(field, '') not in value: break - elif field in {'nottype'}: + elif field == 'nottype': if permission.get('type', '') in value: break - elif field in {'notrole'}: + elif field == 'notrole': if permission.get('role', '') in value: break elif field in {'allowFileDiscovery', 'deleted'}: if value != permission.get(field, False): break - elif field in {'inherited'}: + elif field == 'inherited': if 'permissionDetails' in permission: if value != permission['permissionDetails'][0].get(field, False): break else: break + elif field == 'permissionType': + if 'permissionDetails' in permission: + if value != permission['permissionDetails'][0].get(field, ''): + break + else: + break elif field in {'expirationstart', 'expirationend'}: if 'expirationTime' in permission: expirationDateTime, _ = iso8601.parse_date(permission['expirationTime']) @@ -52736,14 +52794,14 @@ class PermissionMatch(): break else: break - elif field in {'emailaddresslist'}: + elif field == 'emailaddresslist': emailAddress = permission.get('emailAddress') if emailAddress: if emailAddress not in value: break else: break - elif field in {'permissionidlist'}: + elif field == 'permissionidlist': permissionId = permission.get('id') if permissionId: if permissionId not in value: @@ -53141,6 +53199,16 @@ def printFileList(users): if DLP.onlySharedDrives or getPermissionsForSharedDrives or DFF.showSharedDriveNames: _setSkipObjects(skipObjects, ['driveId'], DFF.fieldsList) + def _printFileInfoRow(baserow, fileInfo): + row = baserow.copy() + if not FJQC.formatJSON: + csvPF.WriteRowTitles(flattenJSON(fileInfo, flattened=row, skipObjects=skipObjects, timeObjects=timeObjects, + simpleLists=simpleLists, delimiter=delimiter)) + else: + row['JSON'] = json.dumps(cleanJSON(fileInfo, skipObjects=skipObjects, timeObjects=timeObjects), + ensure_ascii=False, sort_keys=True) + csvPF.WriteRowTitlesJSONNoFilter(row) + def _printFileInfo(drive, user, f_file, cleanFileName): driveId = f_file.get('driveId') checkSharedDrivePermissions = getPermissionsForSharedDrives and driveId and 'permissions' not in f_file @@ -53218,18 +53286,15 @@ def printFileList(users): baserow[fileNameTitle] = fileInfo[fileNameTitle] if 'owners' in fileInfo: flattenJSON({'owners': fileInfo['owners']}, flattened=baserow, skipObjects=skipObjects) - permissions = fileInfo.pop('permissions') - for permission in permissions: - row = baserow.copy() + for permission in fileInfo.pop('permissions'): fileInfo['permission'] = permission - if not FJQC.formatJSON: - csvPF.WriteRowTitles(flattenJSON(fileInfo, flattened=row, skipObjects=skipObjects, timeObjects=timeObjects, - simpleLists=simpleLists, delimiter=delimiter)) + pdetails = fileInfo['permission'].pop('permissionDetails', []) + if not pdetails: + _printFileInfoRow(baserow, fileInfo) else: - row = baserow.copy() - row['JSON'] = json.dumps(cleanJSON(fileInfo, skipObjects=skipObjects, timeObjects=timeObjects), - ensure_ascii=False, sort_keys=True) - csvPF.WriteRowTitlesJSONNoFilter(row) + for pdetail in pdetails: + fileInfo['permission']['permissionDetails'] = pdetail + _printFileInfoRow(baserow, fileInfo) else: if not countsRowFilter: csvPF.UpdateMimeTypeCounts(flattenJSON(fileInfo, flattened=row, skipObjects=skipObjects, timeObjects=timeObjects, @@ -53417,7 +53482,7 @@ def printFileList(users): showLabels = getChoice(SHOWLABELS_CHOICES) elif myarg == 'showshareddrivepermissions': getPermissionsForSharedDrives = True - permissionsFields = 'nextPageToken,permissions' + permissionsFields = f'nextPageToken,permissions({",".join(DRIVEFILE_BASIC_PERMISSION_FIELDS)})' elif myarg == 'pmfilter': pmselect = False elif myarg == 'oneitemperrow': @@ -53459,9 +53524,10 @@ def printFileList(users): DLP.Finalize(fileIdEntity) if DLP.PM.permissionMatches: getPermissionsForSharedDrives = True - permissionsFields = 'nextPageToken,permissions' + permissionsFields = f'nextPageToken,permissions({",".join(DRIVEFILE_BASIC_PERMISSION_FIELDS)})' elif DFF.fieldsList: - getPermissionsForSharedDrives, permissionsFields = _setGetPermissionsForSharedDrives(DFF.fieldsList) + if not getPermissionsForSharedDrives: + getPermissionsForSharedDrives, permissionsFields = _setGetPermissionsForSharedDrives(DFF.fieldsList) if DFF.fieldsList: _setSelectionFields() fields = getFieldsFromFieldsList(DFF.fieldsList) @@ -60875,24 +60941,19 @@ def infoDriveFileACLs(users, useDomainAdminAccess=False): def doInfoDriveFileACLs(): infoDriveFileACLs([_getAdminEmail()], True) -DRIVEFILE_BASIC_PERMISSION_FIELDS = [ - 'displayName', 'id', 'emailAddress', 'domain', 'role', 'type', - 'allowFileDiscovery', 'expirationTime', 'deleted' - ] - DRIVEFILE_PERMISSIONS_FOR_VIEW_CHOICES = ['published'] def getDriveFilePermissionsFields(myarg, fieldsList): if myarg in DRIVE_PERMISSIONS_SUBFIELDS_CHOICE_MAP: fieldsList.append(DRIVE_PERMISSIONS_SUBFIELDS_CHOICE_MAP[myarg]) elif myarg == 'basicpermissions': - fieldsList.extend(DRIVEFILE_BASIC_PERMISSION_FIELDS) + fieldsList.extend(DRIVEFILE_BASIC_PERMISSION_FIELDS[:-1]) elif myarg == 'fields': for field in _getFieldsList(): if field in DRIVE_PERMISSIONS_SUBFIELDS_CHOICE_MAP: fieldsList.append(DRIVE_PERMISSIONS_SUBFIELDS_CHOICE_MAP[field]) elif field == 'basicpermissions': - fieldsList.extend(DRIVEFILE_BASIC_PERMISSION_FIELDS) + fieldsList.extend(DRIVEFILE_BASIC_PERMISSION_FIELDS[:-1]) else: invalidChoiceExit(field, DRIVE_PERMISSIONS_SUBFIELDS_CHOICE_MAP, True) else: @@ -60916,6 +60977,17 @@ def getDriveFilePermissionsFields(myarg, fieldsList): # (orderby [ascending|descending])* # [formatjson] [adminaccess|asadmin] def printShowDriveFileACLs(users, useDomainAdminAccess=False): + def _printPermissionRow(baserow, permission): + row = baserow.copy() + flattenJSON({'permission': permission}, flattened=row, timeObjects=timeObjects) + if not FJQC.formatJSON: + csvPF.WriteRowTitles(row) + elif csvPF.CheckRowTitles(row): + row = baserow.copy() + row['JSON'] = json.dumps(cleanJSON({'permission': permission}, timeObjects=timeObjects), + ensure_ascii=False, sort_keys=True) + csvPF.WriteRowNoFilter(row) + csvPF = CSVPrintFile(['Owner', 'id'], 'sortall') if Act.csvFormat() else None FJQC = FormatJSONQuoteChar(csvPF) fileIdEntity = getDriveFileEntity() @@ -61036,16 +61108,14 @@ def printShowDriveFileACLs(users, useDomainAdminAccess=False): baserow[fileNameTitle] = fileName if oneItemPerRow: for permission in permissions: - row = baserow.copy() _mapDrivePermissionNames(permission) - flattenJSON({'permission': permission}, flattened=row, timeObjects=timeObjects) - if not FJQC.formatJSON: - csvPF.WriteRowTitles(row) - elif csvPF.CheckRowTitles(row): - row = baserow.copy() - row['JSON'] = json.dumps(cleanJSON({'permission': permission}, timeObjects=timeObjects), - ensure_ascii=False, sort_keys=True) - csvPF.WriteRowNoFilter(row) + pdetails = permission.pop('permissionDetails', []) + if not pdetails: + _printPermissionRow(baserow, permission) + else: + for pdetail in pdetails: + permission['permissionDetails'] = pdetail + _printPermissionRow(baserow, permission) else: row = baserow.copy() for permission in permissions: @@ -62287,6 +62357,17 @@ SHOW_NO_PERMISSIONS_DRIVES_CHOICE_MAP = { # [shownopermissionsdrives false|true|only] # [formatjsn] def printShowSharedDriveACLs(users, useDomainAdminAccess=False): + def _printPermissionRow(baserow, permission): + row = baserow.copy() + flattenJSON({'permission': permission}, flattened=row, timeObjects=timeObjects) + if not FJQC.formatJSON: + csvPF.WriteRowTitles(row) + elif csvPF.CheckRowTitles(row): + row = baserow.copy() + row['JSON'] = json.dumps(cleanJSON({'permission': permission}, timeObjects=timeObjects), + ensure_ascii=False, sort_keys=True) + csvPF.WriteRowNoFilter(row) + csvPF = CSVPrintFile(['User', 'id', 'name', 'createdTime'], 'sortall') if Act.csvFormat() else None FJQC = FormatJSONQuoteChar(csvPF) roles = set() @@ -62469,16 +62550,14 @@ def printShowSharedDriveACLs(users, useDomainAdminAccess=False): baserow = {'User': user, 'id': shareddrive['id'], 'name': shareddrive['name'], 'createdTime': shareddrive['createdTime']} if shareddrive['permissions']: for permission in shareddrive['permissions']: - row = baserow.copy() _mapDrivePermissionNames(permission) - flattenJSON({'permission': permission}, flattened=row, timeObjects=timeObjects) - if not FJQC.formatJSON: - csvPF.WriteRowTitles(row) - elif csvPF.CheckRowTitles(row): - row = baserow.copy() - row['JSON'] = json.dumps(cleanJSON({'permission': permission}, timeObjects=timeObjects), - ensure_ascii=False, sort_keys=True) - csvPF.WriteRowNoFilter(row) + pdetails = permission.pop('permissionDetails', []) + if not pdetails: + _printPermissionRow(baserow, permission) + else: + for pdetail in pdetails: + permission['permissionDetails'] = pdetail + _printPermissionRow(baserow, permission) else: if not FJQC.formatJSON: csvPF.WriteRowTitles(baserow) @@ -71448,6 +71527,7 @@ MAIN_COMMANDS_WITH_OBJECTS = { Cmd.ARG_CHROMENETWORK: doDeleteChromeNetwork, Cmd.ARG_CHROMEPOLICY: doDeleteChromePolicy, Cmd.ARG_CIGROUP: doDeleteCIGroups, + Cmd.ARG_CLASSROOMINVITATION: doDeleteClassroomInvitations, Cmd.ARG_CONTACT: doDeleteDomainContacts, Cmd.ARG_CONTACTPHOTO: doDeleteDomainContactPhoto, Cmd.ARG_COURSE: doDeleteCourse,