mirror of
https://github.com/GAM-team/GAM.git
synced 2026-06-06 23:31:38 +00:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e56b56612f | ||
|
|
550dd6b947 | ||
|
|
38f63188e3 | ||
|
|
8d0865f692 | ||
|
|
2ba6ad10c2 | ||
|
|
9e44511872 | ||
|
|
1ee31b15b2 | ||
|
|
dfb1dd860f | ||
|
|
7aaf8cdaa6 | ||
|
|
66a65a380e | ||
|
|
471bd4f924 | ||
|
|
5525c79f5b | ||
|
|
03d79ec62e | ||
|
|
6f72c9844b | ||
|
|
5b1d876101 | ||
|
|
1cb3223637 | ||
|
|
0330e315d2 | ||
|
|
b8f894fddb | ||
|
|
0b1fee6bc5 | ||
|
|
cc61691f28 | ||
|
|
0ece44575d | ||
|
|
e476ad93ba | ||
|
|
21ba5e3ac0 | ||
|
|
453f3faf62 | ||
|
|
296e629b69 | ||
|
|
a319eb665d | ||
|
|
c3bf865497 | ||
|
|
c8914ddb03 | ||
|
|
e24e127055 | ||
|
|
ed2801d612 | ||
|
|
245c9ca9d7 | ||
|
|
e4352129db | ||
|
|
726f061b16 | ||
|
|
75598e5eb8 | ||
|
|
6e57ae33e8 | ||
|
|
22bc5457a7 | ||
|
|
1ba183e8a5 | ||
|
|
26f121b120 | ||
|
|
c3dc91d61d | ||
|
|
5a43aac095 | ||
|
|
0e335322dd | ||
|
|
c5215392d6 |
34
.github/workflows/build.yml
vendored
34
.github/workflows/build.yml
vendored
@@ -643,6 +643,7 @@ jobs:
|
||||
# arm64 needs to build a wheel and needs scons to build
|
||||
sudo apt-get -qq --yes install scons
|
||||
"${PYTHON}" -m pip install --upgrade patchelf-wrapper
|
||||
"${PYTHON}" -m pip install --upgrade typing_extensions
|
||||
# "${PYTHON}" -m pip install --upgrade staticx
|
||||
# install latest github src for staticx
|
||||
"${PYTHON}" -m pip install --upgrade "git+https://github.com/JonathonReinhart/staticx"
|
||||
@@ -689,20 +690,6 @@ jobs:
|
||||
echo "GAM Version ${GAMVERSION}"
|
||||
echo "GAMVERSION=${GAMVERSION}" >> $GITHUB_ENV
|
||||
|
||||
- name: Initialize Windows Desktop Shell
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Checking for Windows Explorer shell..."
|
||||
if (-not (Get-Process -Name explorer -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "Explorer not found. Booting the desktop shell..."
|
||||
Start-Process explorer.exe
|
||||
# Give the desktop a few seconds to fully render the taskbar
|
||||
Start-Sleep -Seconds 10
|
||||
} else {
|
||||
Write-Host "Explorer is already running."
|
||||
}
|
||||
|
||||
- name: Install NPM deps
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
@@ -715,8 +702,8 @@ jobs:
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
#$url = "https://files.certum.eu/software/SimplySignDesktop/Windows/9.3.4.72/SimplySignDesktop-9.3.4.72-64-bit-en.msi"
|
||||
$url = "https://www.files.certum.eu/software/SimplySignDesktop/Windows/9.4.0.84/SimplySignDesktop-9.4.0.84-64-bit-en.msi"
|
||||
#$url = "https://files.certum.eu/software/SimplySignDesktop/Windows/9.4.2.86/SimplySignDesktop-9.4.2.86-64-bit-en.msi"
|
||||
$url = "https://files.certum.eu/software/SimplySignDesktop/Windows/9.4.3.90/SimplySignDesktop-9.4.3.90-64-bit-en.msi"
|
||||
$file = "SimplySignDesktop.msi"
|
||||
Invoke-WebRequest $url -OutFile $file
|
||||
$log = "install.log"
|
||||
@@ -725,6 +712,15 @@ jobs:
|
||||
$procMain.WaitForExit()
|
||||
$procLog.Kill()
|
||||
|
||||
- name: Run SSD directly
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Running SSD..."
|
||||
& "C:\\Program Files\\Certum\\SimplySign Desktop\\SimplySignDesktop.exe"
|
||||
Start-Sleep -Seconds 10
|
||||
& "C:\\Program Files\\Certum\\SimplySign Desktop\\SimplySignDesktop.exe"
|
||||
|
||||
- name: Login to Certum
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
@@ -733,10 +729,11 @@ jobs:
|
||||
run: |
|
||||
write-host "running SimplySignDesktop login..."
|
||||
node tools/ssd.mjs --log-level warn
|
||||
Get-ChildItem -Path $env:GITHUB_WORKSPACE
|
||||
write-host "sleeping during login..."
|
||||
Start-Sleep 10
|
||||
|
||||
- name: Archive png artifacts
|
||||
- name: Archive artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # 7.0.0
|
||||
if: runner.os == 'Windows'
|
||||
with:
|
||||
@@ -744,7 +741,8 @@ jobs:
|
||||
name: images-${{ matrix.os }}
|
||||
if-no-files-found: ignore
|
||||
path: |
|
||||
*.png
|
||||
${{ github.workspace }}/*.png
|
||||
${{ github.workspace }}/*.log
|
||||
|
||||
- name: Sign gam.exe
|
||||
if: runner.os == 'Windows'
|
||||
|
||||
@@ -16,7 +16,7 @@ this will download GAM, install it and start setup.
|
||||
|
||||
## Windows
|
||||
|
||||
Download the MSI Installer from the [GitHub Releases] page. Install the MSI and you'll be prompted to setup GAM.
|
||||
Download the EXE Installer from the [GitHub Releases] page. Run it and you'll be prompted to setup GAM.
|
||||
|
||||
## Use your own Python
|
||||
If you'd prefer to install GAM as a Python package you can install with pip:
|
||||
|
||||
@@ -3162,6 +3162,7 @@ gam <UserTypeEntity> show contactdelegates [shownames] [csv]
|
||||
name|
|
||||
owneremail|
|
||||
ownerid|
|
||||
ownername|
|
||||
room|
|
||||
section|
|
||||
students|
|
||||
@@ -3275,16 +3276,16 @@ gam courses <CourseEntity> sync teachers [addonly|removeonly] [makefirstteachero
|
||||
gam courses <CourseEntity> sync students [addonly|removeonly] <UserTypeEntity>
|
||||
|
||||
gam info course <CourseID> [owneraccess]
|
||||
[owneremail] [alias|aliases] [show all|students|teachers] [countsonly]
|
||||
[owneremail] [ownername] [alias|aliases] [show all|students|teachers] [countsonly]
|
||||
[fields <CourseFieldNameList>] [skipfields <CourseFieldNameList>]
|
||||
[formatjson]
|
||||
gam info courses <CourseEntity> [owneraccess]
|
||||
[owneremail] [alias|aliases] [show all|students|teachers] [countsonly]
|
||||
[owneremail] [ownername] [alias|aliases] [show all|students|teachers] [countsonly]
|
||||
[fields <CourseFieldNameList>] [skipfields <CourseFieldNameList>]
|
||||
[formatjson]
|
||||
gam print courses [todrive <ToDriveAttribute>*]
|
||||
(course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] [states <CourseStateList>])
|
||||
[owneremail] [owneremailmatchpattern <REMatchPattern>]
|
||||
[owneremail] [owneremailmatchpattern <REMatchPattern>] [ownername]
|
||||
[alias|aliases|aliasesincolumns [delimiter <Character>]]
|
||||
[show all|students|teachers] [countsonly]
|
||||
[timefilter creationtime|updatetime] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>]
|
||||
@@ -3398,14 +3399,16 @@ gam print course-announcements [todrive <ToDriveAttribute>*]
|
||||
(course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] states <CourseStateList>])
|
||||
(announcementids <CourseAnnouncementIDEntity>)|((announcementstates <CourseAnnouncementStateList>)*
|
||||
(orderby <CourseAnnouncementOrderByFieldName> [ascending|descending])*)
|
||||
[showcreatoremails|creatoremail] [fields <CourseAnnouncementFieldNameList>]
|
||||
[showcreatoremails|creatoremail] [showcreatornames|creatorname]
|
||||
[fields <CourseAnnouncementFieldNameList>]
|
||||
[timefilter creationtime|updatetime|scheduledtime] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>]
|
||||
[countsonly] [formatjson [quotechar <Character>]]
|
||||
gam print course-materials [todrive <ToDriveAttribute>*]
|
||||
(course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] states <CourseStateList>])
|
||||
(materialids <CourseMaterialIDEntity>)|((materialstates <CourseMaterialStateList>)*
|
||||
(orderby <CourseMaterialOrderByFieldName> [ascending|descending])*)
|
||||
[showcreatoremails|creatoremail] [showtopicnames] [fields <CourseMaterialFieldNameList>]
|
||||
[showcreatoremails|creatoremail] [showcreatornames|creatorname] [showtopicnames]
|
||||
[fields <CourseMaterialFieldNameList>]
|
||||
[timefilter creationtime|updatetime|scheduledtime] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>]
|
||||
[oneitemperrow]
|
||||
[countsonly] [formatjson [quotechar <Character>]]
|
||||
@@ -3426,7 +3429,8 @@ gam print course-works [todrive <ToDriveAttribute>*]
|
||||
(course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] states <CourseStateList>])
|
||||
(workids <CourseWorkIDEntity>)|((workstates <CourseWorkStateList>)*
|
||||
(orderby <CourseWorkOrderByFieldName> [ascending|descending])*)
|
||||
[showcreatoremails|creatoremail] [showtopicnames] [fields <CourseWorkFieldNameList>]
|
||||
[showcreatoremails|creatoremail] [showcreatornames|creatorname] [showtopicnames]
|
||||
[fields <CourseWorkFieldNameList>]
|
||||
[showstudentsaslist [<Boolean>]] [delimiter <Character>]
|
||||
[timefilter creationtime|updatetime|scheduledtime] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>]
|
||||
[oneitemperrow]
|
||||
@@ -3884,6 +3888,8 @@ gam audit monitor list <EmailAddress>
|
||||
gam create|add group <EmailAddress>
|
||||
[copyfrom <GroupItem>] <GroupAttribute>*
|
||||
[verifynotinvitable]
|
||||
[recentdeleteretries <Integer>] [recentdeleteretrydelay <Integer>]
|
||||
[verifycreationretries <Integer>] [verifycreationinitialdelay <Integer>] [verifycreationretrydelay <Integer>]
|
||||
gam update group|groups <GroupEntity> [email <EmailAddress>]
|
||||
[updateprimaryemail <RESearchPattern> <RESubstitution>]
|
||||
[copyfrom <GroupItem>] <GroupAttribute>*
|
||||
|
||||
@@ -1,3 +1,54 @@
|
||||
7.41.03
|
||||
|
||||
Fixed bug in the following:
|
||||
Added the following to `<RowValueFilter>` used in CSV input/output row filtering; these are
|
||||
synonyms for `count` and `countrange`.
|
||||
```
|
||||
[(any|all):]number<Operator><Number>|
|
||||
[(any|all):]numberrange!=<Number>/<Number>|
|
||||
[(any|all):]numberrange=<Number>/<Number>|
|
||||
```
|
||||
|
||||
7.41.02
|
||||
|
||||
Added option `ownername` to `gam info|print courses` to have GAM display the course owners full name;
|
||||
there is an extra API call per course to get the name.
|
||||
|
||||
Added option `creatorname` to `gam print course-announcements|course-materials|course-works` to have
|
||||
GAM display the item creators full name; there is an extra API call per course to get the name.
|
||||
|
||||
After creating a group, it may be sometime, e.g. 30-45 seconds, before members can
|
||||
successfully be added to the group even though the API reported that the group was created.
|
||||
The following options can be used with `gam create group` to verify that the group is actually ready to be updated.
|
||||
This will be most useful in scripts that are used to create and then populate groups.
|
||||
```
|
||||
verifycreationretries <Integer> - Verify group creation, defaults to 0, no verification performed, range 0-20
|
||||
verifycreationinitialdelay <Integer> - Number of seconds to delay before first verification performed, defaults to 5, range 0-60
|
||||
verifycreationretrydelay <Integer> - Number of seconds to delay between verificaton retries, defaults to 5, range 1-60
|
||||
```
|
||||
|
||||
If you have a script that deletes a group and then immediately tries to create a new group with the same email address,
|
||||
you may run into issues. There seems to be a 30-45 second window after the deletion in which a couple
|
||||
of strange errors can occur on the creation: `Resource not found` and `Duplicate`.
|
||||
The following options can be used with `gam create group` to handle these errors. This will be most useful
|
||||
in scripts that are used to delete and then immediately recreate groups.
|
||||
```
|
||||
recentdeleteretries <Integer> - Handle group delete/create errors, defaults to 0, no errors handled, range 0-20
|
||||
recentdeleteretrydelay <Integer> - Number of seconds to delay between retries, defaults to 5, range 1-60
|
||||
```
|
||||
|
||||
Added the following to `<RowValueFilter>` used in CSV input/output row filtering; these are
|
||||
synonyms for `count` and `countrange`.
|
||||
```
|
||||
[(any|all):]number<Operator><Number>|
|
||||
[(any|all):]numberrange!=<Number>/<Number>|
|
||||
[(any|all):]numberrange=<Number>/<Number>|
|
||||
```
|
||||
|
||||
7.41.01
|
||||
|
||||
Fixed bug in `gam print cigroups members managers owners countsonly totalcount internal external` that caused a trap.
|
||||
|
||||
7.41.00
|
||||
|
||||
Upgraded to Python 3.14.4 and OpenSSL 4.0.0.
|
||||
|
||||
@@ -25,7 +25,7 @@ https://github.com/GAM-team/GAM/wiki
|
||||
"""
|
||||
|
||||
__author__ = 'GAM Team <google-apps-manager@googlegroups.com>'
|
||||
__version__ = '7.41.00'
|
||||
__version__ = '7.41.03'
|
||||
__license__ = 'Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0)'
|
||||
|
||||
# pylint: disable=wrong-import-position
|
||||
@@ -114,6 +114,8 @@ from pathvalidate import sanitize_filename, sanitize_filepath
|
||||
import google.oauth2.credentials
|
||||
import google.oauth2.id_token
|
||||
import google.auth
|
||||
import google.auth.transport.requests
|
||||
from google.auth.compute_engine import _metadata as gce_metadata
|
||||
from google.auth.jwt import Credentials as JWTCredentials
|
||||
import google.oauth2.service_account
|
||||
import google_auth_oauthlib.flow
|
||||
@@ -3579,8 +3581,8 @@ def SetGlobalVariables():
|
||||
return headerFilters
|
||||
|
||||
ROW_FILTER_ANY_ALL_PATTERN = re.compile(r'^(any:|all:)(.+)$', re.IGNORECASE)
|
||||
ROW_FILTER_COMP_PATTERN = re.compile(r'^(date|time|count|length)\s*([<>]=?|=|!=)(.+)$', re.IGNORECASE)
|
||||
ROW_FILTER_RANGE_PATTERN = re.compile(r'^(daterange|timerange|countrange|lengthrange)(=|!=)(\S+)/(\S+)$', re.IGNORECASE)
|
||||
ROW_FILTER_COMP_PATTERN = re.compile(r'^(date|time|count|length|number)\s*([<>]=?|=|!=)(.+)$', re.IGNORECASE)
|
||||
ROW_FILTER_RANGE_PATTERN = re.compile(r'^(daterange|timerange|countrange|lengthrange|numberrange)(=|!=)(\S+)/(\S+)$', re.IGNORECASE)
|
||||
ROW_FILTER_TIMEOFDAYRANGE_PATTERN = re.compile(r'^(timeofdayrange)(=|!=)(\d\d):(\d\d)/(\d\d):(\d\d)$', re.IGNORECASE)
|
||||
ROW_FILTER_BOOL_PATTERN = re.compile(r'^(boolean):(.+)$', re.IGNORECASE)
|
||||
ROW_FILTER_TEXT_PATTERN = re.compile(r'^(text)([<>]=?|=|!=)(.*)$', re.IGNORECASE)
|
||||
@@ -3646,7 +3648,7 @@ def SetGlobalVariables():
|
||||
rowFilters.append((columnPat, anyMatch, filterType, mg.group(2), filterValue))
|
||||
else:
|
||||
_printValueError(sectionName, itemName, f'"{column}": "{filterStr}"', f'{Msg.EXPECTED}: {filterValue}')
|
||||
else: # filterType in {'count', 'length'}:
|
||||
else: # filterType in {'count', 'length', 'number'}:
|
||||
if mg.group(3).isdigit():
|
||||
rowFilters.append((columnPat, anyMatch, filterType, mg.group(2), int(mg.group(3))))
|
||||
else:
|
||||
@@ -3676,7 +3678,7 @@ def SetGlobalVariables():
|
||||
rowFilters.append((columnPat, anyMatch, filterType, mg.group(2), filterValue1, filterValue2))
|
||||
else:
|
||||
_printValueError(sectionName, itemName, f'"{column}": "{filterStr}"', f'{Msg.EXPECTED}: {filterValue1}/{filterValue2}')
|
||||
else: #countrange|lengthrange
|
||||
else: #countrange|lengthrange|numberrange
|
||||
if mg.group(3).isdigit() and mg.group(4).isdigit():
|
||||
rowFilters.append((columnPat, anyMatch, filterType, mg.group(2), int(mg.group(3)), int(mg.group(4))))
|
||||
else:
|
||||
@@ -4221,6 +4223,9 @@ def SetGlobalVariables():
|
||||
GM.Globals[GM.OAUTH2_TXT_LOCK] = f'{GC.Values[GC.OAUTH2_TXT]}.lock'
|
||||
# Override httplib2 settings
|
||||
httplib2.debuglevel = GC.Values[GC.DEBUG_LEVEL]
|
||||
# Override requests debuglevel also. Requests is used with
|
||||
# SignJWT/WIF/GCE and a few other places.
|
||||
http.client.HTTPConnection.debuglevel = GC.Values[GC.DEBUG_LEVEL]
|
||||
# Use our own print function for http.client so we can redact and cleanup
|
||||
http.client.print = redactable_debug_print
|
||||
# Reset global variables if required
|
||||
@@ -4516,13 +4521,6 @@ class signjwtJWTCredentials(google.auth.jwt.Credentials):
|
||||
jwt = self._signer.sign(payload)
|
||||
return jwt, expiry.naive
|
||||
|
||||
# Some Workforce Identity Federation endpoints such as GitHub Actions
|
||||
# only allow TLS 1.2 as of April 2023.
|
||||
|
||||
def getTLSv1_2Request():
|
||||
httpc = getHttpObj(override_min_tls='TLSv1_2')
|
||||
return transportCreateRequest(httpc)
|
||||
|
||||
class signjwtCredentials(google.oauth2.service_account.Credentials):
|
||||
''' Class used for DwD '''
|
||||
|
||||
@@ -4543,6 +4541,15 @@ class signjwtCredentials(google.oauth2.service_account.Credentials):
|
||||
token = self._signer(payload)
|
||||
return token
|
||||
|
||||
def get_adc_request():
|
||||
request = google.auth.transport.requests.Request()
|
||||
if GM.Globals[GM.IS_ON_GCE]:
|
||||
return request
|
||||
if gce_metadata.is_on_gce(request):
|
||||
GM.Globals[GM.IS_ON_GCE] = True
|
||||
return request
|
||||
return transportCreateRequest()
|
||||
|
||||
class signjwtSignJwt(google.auth.crypt.Signer):
|
||||
''' Signer class for SignJWT '''
|
||||
def __init__(self, service_account_info):
|
||||
@@ -4556,12 +4563,15 @@ class signjwtSignJwt(google.auth.crypt.Signer):
|
||||
|
||||
def sign(self, message):
|
||||
''' Call IAM Credentials SignJWT API to get our signed JWT '''
|
||||
request = get_adc_request()
|
||||
try:
|
||||
credentials, _ = google.auth.default(scopes=[API.IAM_SCOPE],
|
||||
request=getTLSv1_2Request())
|
||||
request=request)
|
||||
except (google.auth.exceptions.DefaultCredentialsError, google.auth.exceptions.RefreshError) as e:
|
||||
systemErrorExit(API_ACCESS_DENIED_RC, str(e))
|
||||
httpObj = transportAuthorizedHttp(credentials, http=getHttpObj(override_min_tls='TLSv1_2'))
|
||||
httpObj = transportAuthorizedHttp(credentials, http=getHttpObj())
|
||||
# refresh here so we can use the proper request from above
|
||||
httpObj.credentials.refresh(request)
|
||||
iamc = getService(API.IAM_CREDENTIALS, httpObj)
|
||||
response = callGAPI(iamc.projects().serviceAccounts(), 'signJwt',
|
||||
name=self.name, body={'payload': json.dumps(message)})
|
||||
@@ -7827,10 +7837,10 @@ def RowFilterMatch(row, titlesList, rowFilter, rowFilterModeAll, rowDropFilter,
|
||||
elif filterVal[2] == 'timeofdayrange':
|
||||
if rowTimeOfDayRangeFilterMatch(filterVal[3], filterVal[4], filterVal[5]):
|
||||
return True
|
||||
elif filterVal[2] == 'count':
|
||||
elif filterVal[2] in {'count', 'number'}:
|
||||
if rowCountFilterMatch(filterVal[3], filterVal[4]):
|
||||
return True
|
||||
elif filterVal[2] == 'countrange':
|
||||
elif filterVal[2] in {'countrange', 'numberrange'}:
|
||||
if rowCountRangeFilterMatch(filterVal[3], filterVal[4], filterVal[5]):
|
||||
return True
|
||||
elif filterVal[2] == 'length':
|
||||
@@ -11615,7 +11625,7 @@ def doEnableAPIs():
|
||||
automatic = False
|
||||
else:
|
||||
unknownArgumentExit()
|
||||
request = getTLSv1_2Request()
|
||||
request = get_adc_request()
|
||||
try:
|
||||
_, projectId = google.auth.default(scopes=[API.IAM_SCOPE], request=request)
|
||||
except (google.auth.exceptions.DefaultCredentialsError, google.auth.exceptions.RefreshError):
|
||||
@@ -13200,7 +13210,7 @@ def doCreateGCPServiceAccount():
|
||||
checkForExtraneousArguments()
|
||||
_checkForExistingProjectFiles([GC.Values[GC.OAUTH2SERVICE_JSON]])
|
||||
sa_info = {'key_type': 'signjwt', 'token_uri': API.GOOGLE_OAUTH2_TOKEN_ENDPOINT, 'type': 'service_account'}
|
||||
request = getTLSv1_2Request()
|
||||
request = get_adc_request()
|
||||
try:
|
||||
credentials, sa_info['project_id'] = google.auth.default(scopes=[API.IAM_SCOPE], request=request)
|
||||
except (google.auth.exceptions.DefaultCredentialsError, google.auth.exceptions.RefreshError) as e:
|
||||
@@ -34008,9 +34018,20 @@ GROUP_ACCESS_TYPE_CHOICE_MAP = {
|
||||
|
||||
# gam create group <EmailAddress> [copyfrom <GroupItem>] <GroupAttribute>*
|
||||
# [verifynotinvitable]
|
||||
# [verifyduplicateretries <Integer>] [verifyduplicateretrydelay <Integer>]
|
||||
# [verifycreationretries <Integer>] [verifycreationinitialdelay <Integer>] [verifycreationretrydelay <Integer>]
|
||||
def doCreateGroup(ciGroupsAPI=False):
|
||||
def waitingForCreationToComplete(sleep_time):
|
||||
writeStderr(Ind.Spaces()+Msg.WAITING_FOR_ITEM_CREATION_TO_COMPLETE_SLEEPING.format(Ent.Singular(Ent.GROUP), sleep_time))
|
||||
time.sleep(sleep_time)
|
||||
|
||||
cd = buildGAPIObject(API.DIRECTORY)
|
||||
verifyNotInvitable = getBeforeUpdate = False
|
||||
recentDeleteRetries = 0
|
||||
recentDeleteRetryDelay = 5
|
||||
verifyCreationRetries = 0
|
||||
verifyCreationInitialDelay = 5
|
||||
verifyCreationRetryDelay = 5
|
||||
groupEmail = getEmailAddress(noUid=True)
|
||||
entityType = GROUP_CIGROUP_ENTITYTYPE_MAP[ciGroupsAPI]
|
||||
if not ciGroupsAPI:
|
||||
@@ -34050,6 +34071,16 @@ def doCreateGroup(ciGroupsAPI=False):
|
||||
body['labels'][CIGROUP_LOCKED_LABEL] = ''
|
||||
elif myarg == 'verifynotinvitable':
|
||||
verifyNotInvitable = True
|
||||
elif myarg == 'recentdeleteretries':
|
||||
recentDeleteRetries = getInteger(minVal=0, maxVal=20)
|
||||
elif myarg == 'recentdeleteretrydelay':
|
||||
recentDeleteRetryDelay = getInteger(minVal=1, maxVal=60)
|
||||
elif myarg == 'verifycreationretries':
|
||||
verifyCreationRetries = getInteger(minVal=0, maxVal=20)
|
||||
elif myarg == 'verifycreationinitialdelay':
|
||||
verifyCreationInitialDelay = getInteger(minVal=0, maxVal=60)
|
||||
elif myarg == 'verifycreationretrydelay':
|
||||
verifyCreationRetryDelay = getInteger(minVal=1, maxVal=60)
|
||||
else:
|
||||
getGroupAttrValue(myarg, gs_body)
|
||||
if verifyNotInvitable:
|
||||
@@ -34070,38 +34101,89 @@ def doCreateGroup(ciGroupsAPI=False):
|
||||
return
|
||||
if not getBeforeUpdate:
|
||||
settings = gs_body
|
||||
try:
|
||||
if not ciGroupsAPI:
|
||||
callGAPI(cd.groups(), 'insert',
|
||||
throwReasons=GAPI.GROUP_CREATE_THROW_REASONS,
|
||||
body=body, fields='')
|
||||
else:
|
||||
callGAPI(ci.groups(), 'create',
|
||||
throwReasons=GAPI.CIGROUP_CREATE_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
|
||||
initialGroupConfig=initialGroupConfig, body=body, fields='')
|
||||
if gs_body and not GroupIsAbuseOrPostmaster(groupEmail):
|
||||
if getBeforeUpdate:
|
||||
settings = callGAPI(gs.groups(), 'get',
|
||||
throwReasons=GAPI.GROUP_SETTINGS_THROW_REASONS,
|
||||
retryReasons=GAPI.GROUP_SETTINGS_RETRY_REASONS+[GAPI.NOT_FOUND],
|
||||
groupUniqueId=mapGroupEmailForSettings(groupEmail), fields='*')
|
||||
settings.update(gs_body)
|
||||
callGAPI(gs.groups(), 'update',
|
||||
bailOnInvalidError='messageModerationLevel' in settings,
|
||||
throwReasons=GAPI.GROUP_SETTINGS_THROW_REASONS,
|
||||
retryReasons=GAPI.GROUP_SETTINGS_RETRY_REASONS+[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT],
|
||||
groupUniqueId=mapGroupEmailForSettings(groupEmail), body=settings, fields='')
|
||||
entityActionPerformed([entityType, groupEmail])
|
||||
except (GAPI.alreadyExists, GAPI.duplicate):
|
||||
duplicateAliasGroupUserWarning(cd, [entityType, groupEmail])
|
||||
except GAPI.notFound:
|
||||
entityActionFailedWarning([entityType, groupEmail], Msg.DOES_NOT_EXIST)
|
||||
except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.backendError,
|
||||
GAPI.invalid, GAPI.invalidArgument, GAPI.invalidAttributeValue, GAPI.invalidInput, GAPI.invalidArgument, GAPI.failedPrecondition,
|
||||
GAPI.badRequest, GAPI.permissionDenied, GAPI.systemError, GAPI.serviceLimit, GAPI.serviceNotAvailable, GAPI.authError) as e:
|
||||
entityActionFailedWarning([entityType, groupEmail], str(e))
|
||||
except GAPI.required:
|
||||
entityActionFailedWarning([entityType, groupEmail], Msg.INVALID_JSON_SETTING)
|
||||
duplicateRetries = 0
|
||||
while True:
|
||||
try:
|
||||
if not ciGroupsAPI:
|
||||
callGAPI(cd.groups(), 'insert',
|
||||
throwReasons=GAPI.GROUP_CREATE_THROW_REASONS,
|
||||
body=body, fields='')
|
||||
else:
|
||||
callGAPI(ci.groups(), 'create',
|
||||
throwReasons=GAPI.CIGROUP_CREATE_THROW_REASONS, retryReasons=GAPI.CIGROUP_RETRY_REASONS,
|
||||
initialGroupConfig=initialGroupConfig, body=body, fields='')
|
||||
if gs_body and not GroupIsAbuseOrPostmaster(groupEmail):
|
||||
groupUniqueId = mapGroupEmailForSettings(groupEmail)
|
||||
if getBeforeUpdate:
|
||||
settings = callGAPI(gs.groups(), 'get',
|
||||
throwReasons=GAPI.GROUP_SETTINGS_THROW_REASONS,
|
||||
retryReasons=GAPI.GROUP_SETTINGS_RETRY_REASONS+[GAPI.NOT_FOUND],
|
||||
groupUniqueId=groupUniqueId, fields='*')
|
||||
settings.update(gs_body)
|
||||
callGAPI(gs.groups(), 'update',
|
||||
bailOnInvalidError='messageModerationLevel' in settings,
|
||||
throwReasons=GAPI.GROUP_SETTINGS_THROW_REASONS,
|
||||
retryReasons=GAPI.GROUP_SETTINGS_RETRY_REASONS+[GAPI.NOT_FOUND, GAPI.INVALID_ARGUMENT],
|
||||
groupUniqueId=groupUniqueId, body=settings, fields='')
|
||||
entityActionPerformed([entityType, groupEmail])
|
||||
break
|
||||
except GAPI.resourceNotFound as e:
|
||||
# If group we're trying to create was just deleted, Google gets confused; sleep and retry
|
||||
duplicateRetries += 1
|
||||
if ciGroupsAPI or duplicateRetries > recentDeleteRetries:
|
||||
entityActionFailedWarning([entityType, groupEmail], str(e))
|
||||
break
|
||||
time.sleep(recentDeleteRetryDelay)
|
||||
continue
|
||||
except (GAPI.alreadyExists, GAPI.duplicate):
|
||||
duplicateRetries += 1
|
||||
if ciGroupsAPI or duplicateRetries > recentDeleteRetries:
|
||||
duplicateAliasGroupUserWarning(cd, [entityType, groupEmail])
|
||||
break
|
||||
time.sleep(recentDeleteRetryDelay)
|
||||
continue
|
||||
# except GAPI.notFound:
|
||||
# entityActionFailedWarning([entityType, groupEmail], Msg.DOES_NOT_EXIST)
|
||||
except (GAPI.groupNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.backendError,
|
||||
GAPI.invalid, GAPI.invalidArgument, GAPI.invalidAttributeValue, GAPI.invalidInput, GAPI.invalidArgument, GAPI.failedPrecondition,
|
||||
GAPI.badRequest, GAPI.permissionDenied, GAPI.systemError, GAPI.serviceLimit, GAPI.serviceNotAvailable, GAPI.authError) as e:
|
||||
entityActionFailedWarning([entityType, groupEmail], str(e))
|
||||
break
|
||||
except GAPI.required:
|
||||
entityActionFailedWarning([entityType, groupEmail], Msg.INVALID_JSON_SETTING)
|
||||
break
|
||||
if ciGroupsAPI or not verifyCreationRetries:
|
||||
return
|
||||
Act.Set(Act.VERIFYITEMEXISTS)
|
||||
action = Act.Get()
|
||||
performAction(Ent.GROUP, groupEmail)
|
||||
Ind.Increment()
|
||||
waitingForCreationToComplete(verifyCreationInitialDelay)
|
||||
retries = 0
|
||||
while True:
|
||||
try:
|
||||
callGAPI(cd.groups(), 'get',
|
||||
throwReasons=GAPI.GROUP_GET_THROW_REASONS, retryReasons=GAPI.GROUP_GET_RETRY_REASONS,
|
||||
groupKey=groupEmail, fields='name')
|
||||
entityActionPerformed([Ent.GROUP, groupEmail])
|
||||
break
|
||||
except GAPI.groupNotFound:
|
||||
retries += 1
|
||||
kvList = [Act.PerformedName(action), False, 'Retry', f'{retries}/{verifyCreationRetries}']
|
||||
printEntityKVList([Ent.GROUP, groupEmail], kvList)
|
||||
if retries >= verifyCreationRetries:
|
||||
entityActionFailedWarning([Ent.GROUP, groupEmail], Msg.RETRIES_EXHAUSTED.format(verifyCreationRetries))
|
||||
break
|
||||
waitingForCreationToComplete(verifyCreationRetryDelay)
|
||||
except (GAPI.resourceNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.backendError,
|
||||
GAPI.invalid, GAPI.invalidArgument, GAPI.invalidMember, GAPI.invalidParameter, GAPI.invalidInput, GAPI.forbidden,
|
||||
GAPI.badRequest, GAPI.permissionDenied, GAPI.systemError, GAPI.serviceLimit, GAPI.serviceNotAvailable, GAPI.authError) as e:
|
||||
entityActionFailedWarning([Ent.GROUP, groupEmail], str(e))
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
entityActionFailedWarning([Ent.GROUP, groupEmail], Msg.CHECK_INTERRUPTED)
|
||||
break
|
||||
Ind.Decrement()
|
||||
|
||||
# [addonly|removeonly]
|
||||
def getSyncOperation():
|
||||
@@ -35649,7 +35731,7 @@ def setMemberDisplayTitles(memberDisplayOptions, csvPF):
|
||||
|
||||
def setMemberDisplaySortTitles(memberDisplayOptions, sortTitles):
|
||||
if memberDisplayOptions['totalCount']:
|
||||
sortTitles.append(MEMBERS_TITLES['total'][0])
|
||||
sortTitles.append(MEMBERS_TITLES['combined']['total'][0])
|
||||
for category in memberDisplayOptions['categories']:
|
||||
if memberDisplayOptions['totalCount'] and category != 'combined':
|
||||
sortTitles.append(MEMBERS_TITLES[category]['total'][0])
|
||||
@@ -50277,6 +50359,7 @@ COURSE_FIELDS_CHOICE_MAP = {
|
||||
'name': 'name',
|
||||
'owneremail': 'ownerId',
|
||||
'ownerid': 'ownerId',
|
||||
'ownername': 'ownerId',
|
||||
'room': 'room',
|
||||
'section': 'section',
|
||||
'teacherfolder': 'teacherFolder',
|
||||
@@ -50299,6 +50382,7 @@ COURSE_PROPERTY_PRINT_ORDER = [
|
||||
'alternateLink',
|
||||
'ownerEmail',
|
||||
'ownerId',
|
||||
'ownerName',
|
||||
'creationTime',
|
||||
'updateTime',
|
||||
'calendarId',
|
||||
@@ -50310,7 +50394,8 @@ COURSE_PROPERTY_PRINT_ORDER = [
|
||||
]
|
||||
|
||||
def _initCourseShowProperties(fields=None):
|
||||
return {'aliases': False, 'aliasesInColumns': False, 'ownerEmail': False, 'ownerEmailMatchPattern': None, 'members': 'none', 'countsOnly': False,
|
||||
return {'aliases': False, 'aliasesInColumns': False, 'ownerEmail': False, 'ownerEmailMatchPattern': None,
|
||||
'ownerName': False, 'members': 'none', 'countsOnly': False,
|
||||
'fields': fields if fields is not None else [], 'skips': []}
|
||||
|
||||
def _getCourseShowProperties(myarg, courseShowProperties):
|
||||
@@ -50325,6 +50410,8 @@ def _getCourseShowProperties(myarg, courseShowProperties):
|
||||
elif myarg == 'owneremailmatchpattern':
|
||||
courseShowProperties['ownerEmail'] = True
|
||||
courseShowProperties['ownerEmailMatchPattern'] = getREPattern(re.IGNORECASE)
|
||||
elif myarg == 'ownername':
|
||||
courseShowProperties['ownerName'] = True
|
||||
elif myarg == 'show':
|
||||
courseShowProperties['members'] = getChoice(COURSE_MEMBER_ARGUMENTS)
|
||||
elif myarg == 'countsonly':
|
||||
@@ -50340,6 +50427,9 @@ def _getCourseShowProperties(myarg, courseShowProperties):
|
||||
elif field == 'owneremail':
|
||||
courseShowProperties['ownerEmail'] = True
|
||||
courseShowProperties['fields'].append(COURSE_FIELDS_CHOICE_MAP[field])
|
||||
elif field == 'ownername':
|
||||
courseShowProperties['ownerName'] = True
|
||||
courseShowProperties['fields'].append(COURSE_FIELDS_CHOICE_MAP[field])
|
||||
elif field == 'teachers':
|
||||
if courseShowProperties['members'] == 'none':
|
||||
courseShowProperties['members'] = field
|
||||
@@ -50381,27 +50471,26 @@ def _setCourseFields(courseShowProperties, pagesMode, getOwnerId=False):
|
||||
if not courseShowProperties['fields']:
|
||||
return None
|
||||
courseShowProperties['fields'].append('id')
|
||||
if courseShowProperties['ownerEmail'] or getOwnerId:
|
||||
if courseShowProperties['ownerEmail'] or courseShowProperties['ownerName'] or getOwnerId:
|
||||
courseShowProperties['fields'].append('ownerId')
|
||||
if not pagesMode:
|
||||
return ','.join(set(courseShowProperties['fields']))
|
||||
return f'nextPageToken,courses({",".join(set(courseShowProperties["fields"]))})'
|
||||
|
||||
def _convertCourseUserIdToEmail(croom, userId, emails, entityValueList, i, count):
|
||||
userEmail = emails.get(userId)
|
||||
if userEmail is None:
|
||||
def _convertCourseUserIdToEmailName(croom, userId, emails, entityValueList, i, count):
|
||||
if userId not in emails:
|
||||
try:
|
||||
userEmail = callGAPI(croom.userProfiles(), 'get',
|
||||
throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.BAD_REQUEST, GAPI.FORBIDDEN, GAPI.SERVICE_NOT_AVAILABLE],
|
||||
retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
|
||||
userId=userId, fields='emailAddress').get('emailAddress')
|
||||
result = callGAPI(croom.userProfiles(), 'get',
|
||||
throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.BAD_REQUEST, GAPI.FORBIDDEN, GAPI.SERVICE_NOT_AVAILABLE],
|
||||
retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
|
||||
userId=userId, fields='emailAddress,name(fullName)')
|
||||
except (GAPI.notFound, GAPI.permissionDenied, GAPI.badRequest, GAPI.forbidden, GAPI.serviceNotAvailable):
|
||||
pass
|
||||
if userEmail is None:
|
||||
result = {}
|
||||
if not result:
|
||||
entityDoesNotHaveItemWarning(entityValueList, i, count)
|
||||
userEmail = 'Unknown user'
|
||||
emails[userId] = userEmail
|
||||
return userEmail
|
||||
emails[userId] = (result.get('emailAddress', 'Unknown user'),
|
||||
result.get('name', {}).get('fullName', 'Unknown user'))
|
||||
return emails[userId]
|
||||
|
||||
def _getCourseOwnerSA(croom, course, useOwnerAccess):
|
||||
if not useOwnerAccess:
|
||||
@@ -50523,9 +50612,13 @@ def _doInfoCourses(courseIdList):
|
||||
throwReasons=[GAPI.NOT_FOUND, GAPI.PERMISSION_DENIED, GAPI.SERVICE_NOT_AVAILABLE],
|
||||
retryReasons=GAPI.SERVICE_NOT_AVAILABLE_RETRY_REASONS,
|
||||
id=courseId, fields=fields)
|
||||
if courseShowProperties['ownerEmail']:
|
||||
course['ownerEmail'] = _convertCourseUserIdToEmail(croom, course['ownerId'], ownerEmails,
|
||||
[Ent.COURSE, course['id'], Ent.OWNER_ID, course['ownerId']], i, count)
|
||||
if courseShowProperties['ownerEmail'] or courseShowProperties['ownerName']:
|
||||
ownerEmail, ownerName = _convertCourseUserIdToEmailName(croom, course['ownerId'], ownerEmails,
|
||||
[Ent.COURSE, course['id'], Ent.OWNER_ID, course['ownerId']], i, count)
|
||||
if courseShowProperties['ownerEmail']:
|
||||
course['ownerEmail'] = ownerEmail
|
||||
if courseShowProperties['ownerName']:
|
||||
course['ownerName'] = ownerName
|
||||
aliases, teachers, students = _getCourseAliasesMembers(croom, courseInfo['croom'], courseId, courseShowProperties, teachersFields, studentsFields)
|
||||
if FJQC.formatJSON:
|
||||
if courseShowProperties['aliases']:
|
||||
@@ -50589,14 +50682,14 @@ def _doInfoCourses(courseIdList):
|
||||
ClientAPIAccessDeniedExit(str(e))
|
||||
|
||||
# gam info courses <CourseEntity> [owneraccess]
|
||||
# [owneremail] [alias|aliases] [show none|all|students|teachers] [countsonly]
|
||||
# [owneremail] [ownername] [alias|aliases] [show none|all|students|teachers] [countsonly]
|
||||
# [fields <CourseFieldNameList>] [skipfields <CourseFieldNameList>]
|
||||
# [formatjson]
|
||||
def doInfoCourses():
|
||||
_doInfoCourses(getEntityList(Cmd.OB_COURSE_ENTITY, shlexSplit=True))
|
||||
|
||||
# gam info course <CourseID> [owneraccess]
|
||||
# [owneremail] [alias|aliases] [show none|all|students|teachers] [countsonly]
|
||||
# [owneremail] [ownername] [alias|aliases] [show none|all|students|teachers] [countsonly]
|
||||
# [fields <CourseFieldNameList>] [skipfields <CourseFieldNameList>]
|
||||
# [formatjson]
|
||||
def doInfoCourse():
|
||||
@@ -50709,7 +50802,7 @@ def _getCoursesInfo(croom, courseSelectionParameters, courseShowProperties, getO
|
||||
return coursesInfo
|
||||
|
||||
# gam print courses [todrive <ToDriveAttribute>*] (course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] [states <CourseStateList>])
|
||||
# [owneremail] [owneremailmatchpattern <REMatchPattern>]
|
||||
# [owneremail] [owneremailmatchpattern <REMatchPattern>] [ownername]
|
||||
# [alias|aliases|aliasesincolumns [delimiter <Character>]]
|
||||
# [show none|all|students|teachers] [countsonly]
|
||||
# [fields <CourseFieldNameList>] [skipfields <CourseFieldNameList>]
|
||||
@@ -50828,11 +50921,15 @@ def doPrintCourses():
|
||||
ocroom = _getCourseOwnerSA(croom, course, useOwnerAccess)
|
||||
if not ocroom:
|
||||
continue
|
||||
if courseShowProperties['ownerEmail']:
|
||||
course['ownerEmail'] = _convertCourseUserIdToEmail(croom, course['ownerId'], ownerEmails,
|
||||
[Ent.COURSE, courseId, Ent.OWNER_ID, course['ownerId']], i, count)
|
||||
if courseShowProperties['ownerEmailMatchPattern'] and not courseShowProperties['ownerEmailMatchPattern'].match(course['ownerEmail']):
|
||||
continue
|
||||
if courseShowProperties['ownerEmail'] or courseShowProperties['ownerName']:
|
||||
ownerEmail, ownerName = _convertCourseUserIdToEmailName(croom, course['ownerId'], ownerEmails,
|
||||
[Ent.COURSE, courseId, Ent.OWNER_ID, course['ownerId']], i, count)
|
||||
if courseShowProperties['ownerEmail']:
|
||||
course['ownerEmail'] = ownerEmail
|
||||
if courseShowProperties['ownerEmailMatchPattern'] and not courseShowProperties['ownerEmailMatchPattern'].match(ownerEmail):
|
||||
continue
|
||||
if courseShowProperties['ownerName']:
|
||||
course['ownerName'] = ownerName
|
||||
if showItemCountOnly:
|
||||
itemCount += 1
|
||||
continue
|
||||
@@ -50934,10 +51031,14 @@ def doPrintCourseAnnouncements():
|
||||
def _printCourseAnnouncement(course, courseAnnouncement, i, count):
|
||||
if applyCourseItemFilter and not _courseItemPassesFilter(courseAnnouncement, courseItemFilter):
|
||||
return
|
||||
if showCreatorEmail:
|
||||
courseAnnouncement['creatorUserEmail'] = _convertCourseUserIdToEmail(croom, courseAnnouncement['creatorUserId'], creatorEmails,
|
||||
[Ent.COURSE, course['id'], Ent.COURSE_ANNOUNCEMENT_ID, courseAnnouncement['id'],
|
||||
Ent.CREATOR_ID, courseAnnouncement['creatorUserId']], i, count)
|
||||
if showCreatorEmail or showCreatorName:
|
||||
creatorUserEmail, creatorUserName = _convertCourseUserIdToEmailName(croom, courseAnnouncement['creatorUserId'], creatorEmails,
|
||||
[Ent.COURSE, course['id'], Ent.COURSE_ANNOUNCEMENT_ID, courseAnnouncement['id'],
|
||||
Ent.CREATOR_ID, courseAnnouncement['creatorUserId']], i, count)
|
||||
if showCreatorEmail:
|
||||
courseAnnouncement['creatorUserEmail'] = creatorUserEmail
|
||||
if showCreatorName:
|
||||
courseAnnouncement['creatorUserName'] = creatorUserName
|
||||
row = flattenJSON(courseAnnouncement, flattened={'courseId': course['id'], 'courseName': course['name']}, timeObjects=COURSE_ANNOUNCEMENTS_TIME_OBJECTS)
|
||||
if not FJQC.formatJSON:
|
||||
csvPF.WriteRowTitles(row)
|
||||
@@ -50957,7 +51058,7 @@ def doPrintCourseAnnouncements():
|
||||
courseAnnouncementStates = []
|
||||
OBY = OrderBy(COURSE_ANNOUNCEMENTS_ORDERBY_CHOICE_MAP)
|
||||
creatorEmails = {}
|
||||
countsOnly = showCreatorEmail = False
|
||||
countsOnly = showCreatorEmail = showCreatorName = False
|
||||
items = 'courseAnnouncements'
|
||||
while Cmd.ArgumentsRemaining():
|
||||
myarg = getArgument()
|
||||
@@ -50975,6 +51076,8 @@ def doPrintCourseAnnouncements():
|
||||
OBY.GetChoice()
|
||||
elif myarg in {'showcreatoremails', 'creatoremail'}:
|
||||
showCreatorEmail = True
|
||||
elif myarg in {'showcreatornames', 'creatorname'}:
|
||||
showCreatorName = True
|
||||
elif getFieldsList(myarg, COURSE_ANNOUNCEMENTS_FIELDS_CHOICE_MAP, fieldsList, initialField='id'):
|
||||
pass
|
||||
elif myarg == 'countsonly':
|
||||
@@ -51232,10 +51335,14 @@ def doPrintCourseWM(entityIDType, entityStateType):
|
||||
def _printCourseWM(course, courseWM, i, count):
|
||||
if applyCourseItemFilter and not _courseItemPassesFilter(courseWM, courseItemFilter):
|
||||
return
|
||||
if showCreatorEmail:
|
||||
courseWM['creatorUserEmail'] = _convertCourseUserIdToEmail(croom, courseWM['creatorUserId'], creatorEmails,
|
||||
[Ent.COURSE, course['id'], entityIDType, courseWM['id'],
|
||||
Ent.CREATOR_ID, courseWM['creatorUserId']], i, count)
|
||||
if showCreatorEmail or showCreatorName:
|
||||
creatorUserEmail, creatorUserName = _convertCourseUserIdToEmailName(croom, courseWM['creatorUserId'], creatorEmails,
|
||||
[Ent.COURSE, course['id'], entityIDType, courseWM['id'],
|
||||
Ent.CREATOR_ID, courseWM['creatorUserId']], i, count)
|
||||
if showCreatorEmail:
|
||||
courseWM['creatorUserEmail'] = creatorUserEmail
|
||||
if showCreatorName:
|
||||
courseWM['creatorUserName'] = creatorUserName
|
||||
if showTopicNames:
|
||||
topicId = courseWM.get('topicId')
|
||||
if topicId:
|
||||
@@ -51284,7 +51391,7 @@ def doPrintCourseWM(entityIDType, entityStateType):
|
||||
courseShowProperties = _initCourseShowProperties(['name'])
|
||||
OBY = OrderBy(OrderbyChoiceMap)
|
||||
creatorEmails = {}
|
||||
oneItemPerRow = showCreatorEmail = showTopicNames = False
|
||||
oneItemPerRow = showCreatorEmail = showCreatorName = showTopicNames = False
|
||||
delimiter = GC.Values[GC.CSV_OUTPUT_FIELD_DELIMITER]
|
||||
countsOnly = showStudentsAsList = False
|
||||
while Cmd.ArgumentsRemaining():
|
||||
@@ -51306,6 +51413,8 @@ def doPrintCourseWM(entityIDType, entityStateType):
|
||||
csvPF.RemoveIndexedTitles('materials')
|
||||
elif myarg in {'showcreatoremails', 'creatoremail'}:
|
||||
showCreatorEmail = True
|
||||
elif myarg in {'showcreatornames', 'creatorname'}:
|
||||
showCreatorName = True
|
||||
elif myarg == 'showtopicnames':
|
||||
showTopicNames = True
|
||||
elif getFieldsList(myarg, FieldsChoiceMap, fieldsList, initialField='id'):
|
||||
@@ -51319,7 +51428,7 @@ def doPrintCourseWM(entityIDType, entityStateType):
|
||||
csvPF.AddTitles(items)
|
||||
else:
|
||||
FJQC.GetFormatJSONQuoteChar(myarg, True)
|
||||
if showCreatorEmail and fieldsList:
|
||||
if (showCreatorEmail or showCreatorName) and fieldsList:
|
||||
fieldsList.append('creatorUserId')
|
||||
if showTopicNames and fieldsList:
|
||||
fieldsList.append('topicId')
|
||||
@@ -51384,7 +51493,8 @@ def doPrintCourseWM(entityIDType, entityStateType):
|
||||
# (course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] states <CourseStateList>])
|
||||
# (materialids <CourseMaterialIDEntity>)|((materialstates <CourseMaterialStateList>)*
|
||||
# (orderby <CourseMaterialsOrderByFieldName> [ascending|descending])*)
|
||||
# [showcreatoremails|creatoremail] [showtopicnames] [fields <CourseMaterialFieldNameList>]
|
||||
# [showcreatoremails|creatoremail] [showcreatornames|creatorname] [showtopicnames]
|
||||
# [fields <CourseMaterialFieldNameList>]
|
||||
# [timefilter creationtime|updatetime|scheduledtime] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>]
|
||||
# [oneitemperrow]
|
||||
# [countsonly|(formatjson [quotechar <Character>])]
|
||||
@@ -51395,7 +51505,8 @@ def doPrintCourseMaterials():
|
||||
# (course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] states <CourseStateList>])
|
||||
# (workids <CourseWorkIDEntity>)|((workstates <CourseWorkStateList>)*
|
||||
# (orderby <CourseWorkOrderByFieldName> [ascending|descending])*)
|
||||
# [showcreatoremails|creatoremail] [showtopicnames] [fields <CourseWorkFieldNameList>]
|
||||
# [showcreatoremails|creatoremail] [showcreatornames|creatorname] [showtopicnames]
|
||||
# [fields <CourseWorkFieldNameList>]
|
||||
# [showstudentsaslist [<Boolean>]] [delimiter <Character>]
|
||||
# [timefilter creationtime|updatetime] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>]
|
||||
# [oneitemperrow]
|
||||
|
||||
@@ -134,6 +134,7 @@ class GamAction():
|
||||
UNZIP = 'unzi'
|
||||
USE = 'use '
|
||||
VERIFY = 'vrfy'
|
||||
VERIFYITEMEXISTS = 'vexi'
|
||||
WAITFORMAILBOX = 'wamb'
|
||||
WATCH = 'watc'
|
||||
WIPE = 'wipe'
|
||||
@@ -253,6 +254,7 @@ class GamAction():
|
||||
UPLOAD: ['Uploaded', 'Upload'],
|
||||
USE: ['Used', 'Use'],
|
||||
VERIFY: ['Verified', 'Verify'],
|
||||
VERIFYITEMEXISTS: ['Verified Item Exists', 'Verify Item Exists'],
|
||||
WAITFORMAILBOX: ['Mailbox is Setup', 'Check Mailbox is Setup'],
|
||||
WATCH: ['Watched', 'Watch'],
|
||||
WIPE: ['Wiped', 'Wipe'],
|
||||
|
||||
@@ -274,7 +274,7 @@ GMAIL_LIST_THROW_REASONS = [FAILED_PRECONDITION, PERMISSION_DENIED, INVALID, INV
|
||||
GMAIL_SMIME_THROW_REASONS = [SERVICE_NOT_AVAILABLE, BAD_REQUEST, INVALID_ARGUMENT, FORBIDDEN, NOT_FOUND, PERMISSION_DENIED]
|
||||
GROUP_GET_THROW_REASONS = [GROUP_NOT_FOUND, DOMAIN_NOT_FOUND, DOMAIN_CANNOT_USE_APIS, FORBIDDEN, BAD_REQUEST, INVALID, SYSTEM_ERROR]
|
||||
GROUP_GET_RETRY_REASONS = [INVALID, SYSTEM_ERROR, SERVICE_NOT_AVAILABLE]
|
||||
GROUP_CREATE_THROW_REASONS = [DUPLICATE, DOMAIN_NOT_FOUND, DOMAIN_CANNOT_USE_APIS, FORBIDDEN, INVALID, INVALID_INPUT]
|
||||
GROUP_CREATE_THROW_REASONS = [DUPLICATE, DOMAIN_NOT_FOUND, DOMAIN_CANNOT_USE_APIS, FORBIDDEN, INVALID, INVALID_INPUT, RESOURCE_NOT_FOUND]
|
||||
GROUP_UPDATE_THROW_REASONS = [GROUP_NOT_FOUND, DOMAIN_NOT_FOUND, DOMAIN_CANNOT_USE_APIS, FORBIDDEN, INVALID, INVALID_INPUT]
|
||||
GROUP_SETTINGS_THROW_REASONS = [NOT_FOUND, GROUP_NOT_FOUND, DOMAIN_NOT_FOUND, DOMAIN_CANNOT_USE_APIS, FORBIDDEN, SYSTEM_ERROR, PERMISSION_DENIED,
|
||||
INVALID, INVALID_ARGUMENT, INVALID_PARAMETER, INVALID_ATTRIBUTE_VALUE, INVALID_INPUT,
|
||||
|
||||
@@ -131,6 +131,8 @@ GAM_PATH = 'gpth'
|
||||
GAM_TYPE = 'gtyp'
|
||||
# Shared Service Account HTTP Object
|
||||
HTTP_OBJECT = 'http'
|
||||
# Are we on Global Compute Engine
|
||||
IS_ON_GCE = 'ogce'
|
||||
# Length of last Got message
|
||||
LAST_GOT_MSG_LEN = 'lgml'
|
||||
# License SKUs
|
||||
@@ -285,6 +287,7 @@ Globals = {
|
||||
GAM_PATH: '.',
|
||||
GAM_TYPE: '',
|
||||
HTTP_OBJECT: None,
|
||||
IS_ON_GCE: False,
|
||||
LAST_GOT_MSG_LEN: 0,
|
||||
LICENSE_SKUS: [],
|
||||
MAKE_BUILDING_ID_NAME_MAP: True,
|
||||
|
||||
@@ -30,6 +30,9 @@ function minimizeAllWindows() {
|
||||
async function takeScreenshot(filename) {
|
||||
const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
|
||||
const fullPath = path.join(workspace, filename);
|
||||
|
||||
// Create a temporary script file path
|
||||
const scriptPath = path.join(workspace, `screenshot_${Date.now()}.ps1`);
|
||||
|
||||
const psScript = `
|
||||
Add-Type -AssemblyName System.Windows.Forms;
|
||||
@@ -44,47 +47,101 @@ async function takeScreenshot(filename) {
|
||||
$bitmap = New-Object System.Drawing.Bitmap $Screen.Width, $Screen.Height;
|
||||
$graphic = [System.Drawing.Graphics]::FromImage($bitmap);
|
||||
$graphic.CopyFromScreen($Screen.Left, $Screen.Top, 0, 0, $bitmap.Size);
|
||||
|
||||
# Save the file (using single quotes safely now)
|
||||
$bitmap.Save('${fullPath}');
|
||||
Write-Output "Wrote ${fullPath}";
|
||||
|
||||
# Specify ItemType to prevent older PS versions from prompting interactively
|
||||
New-Item -Path "${fullPath}.written" -ItemType File | Out-Null;
|
||||
`;
|
||||
|
||||
try {
|
||||
execSync(`powershell -Command "${psScript}"`);
|
||||
// 1. Write the script to disk
|
||||
fs.writeFileSync(scriptPath, psScript);
|
||||
|
||||
// 2. Execute the file directly, piping stdout/stderr to the Node console
|
||||
execSync(`powershell -NoProfile -ExecutionPolicy Bypass -File "${scriptPath}"`, {
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
console.log(`Saved screenshot: ${fullPath}`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to save screenshot ${fullPath}:`, err.message);
|
||||
} finally {
|
||||
// 3. Clean up the temp file so it doesn't clutter your CI artifacts
|
||||
if (fs.existsSync(scriptPath)) {
|
||||
fs.unlinkSync(scriptPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fire and forget application launcher
|
||||
// Fire and forget application launcher with logging
|
||||
function launchSSD() {
|
||||
const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
|
||||
const outLogPath = path.join(workspace, 'ssd_out.log');
|
||||
const errLogPath = path.join(workspace, 'ssd_err.log');
|
||||
|
||||
// Open file descriptors for logging
|
||||
const out = fs.openSync(outLogPath, 'a');
|
||||
const err = fs.openSync(errLogPath, 'a');
|
||||
|
||||
console.log(`Launching SSD... Logging stdout to ${outLogPath} and stderr to ${errLogPath}`);
|
||||
|
||||
const child = spawn('C:\\Program Files\\Certum\\SimplySign Desktop\\SimplySignDesktop.exe', [], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
detached: true,
|
||||
// stdio array: [stdin, stdout, stderr]
|
||||
// We ignore stdin, and pipe stdout/stderr to our files
|
||||
stdio: ['ignore', out, err]
|
||||
});
|
||||
child.unref();
|
||||
|
||||
// Catch immediate errors (e.g., file not found, permission denied)
|
||||
child.on('error', (error) => {
|
||||
console.error('Failed to spawn SimplySign Desktop:', error.message);
|
||||
});
|
||||
|
||||
// Unreference the child so the parent script can exit
|
||||
child.unref();
|
||||
}
|
||||
|
||||
async function runSSD() {
|
||||
await takeScreenshot('001.png');
|
||||
minimizeAllWindows();
|
||||
await sleep(2000);
|
||||
await takeScreenshot('002.png');
|
||||
sendKeys('{ESC}');
|
||||
await sleep(2000);
|
||||
await takeScreenshot('003.png');
|
||||
//sendKeys('{ESC}');
|
||||
//await sleep(2000);
|
||||
//await takeScreenshot('004.png');
|
||||
//sendKeys('{ESC}');
|
||||
//await sleep(2000);
|
||||
//await takeScreenshot('005.png');
|
||||
//sendKeys('%{F4}');
|
||||
//await sleep(2000);
|
||||
//await takeScreenshot('006.png');
|
||||
//sendKeys('%{F4}');
|
||||
//await sleep(2000);
|
||||
//await takeScreenshot('007.png');
|
||||
const runner_arch = process.env.RUNNER_ARCH;
|
||||
if ( runner_arch === "ARM64" ) {
|
||||
console.log('Running on ARM64. Tabbing through OOBE...');
|
||||
await sleep(3000);
|
||||
await takeScreenshot('oob1.png');
|
||||
|
||||
// Page 1: Tab through the toggles to reach the "Next" button
|
||||
for (let i = 0; i < 7; i++) {
|
||||
sendKeys('{TAB}');
|
||||
await sleep(200);
|
||||
}
|
||||
sendKeys('{ENTER}');
|
||||
console.log('Clicked Next');
|
||||
|
||||
await sleep(3000);
|
||||
await takeScreenshot('ooob2.png');
|
||||
|
||||
// Page 2: Tab through the remaining toggles to reach the "Accept" button
|
||||
for (let i = 0; i < 7; i++) {
|
||||
sendKeys('{TAB}');
|
||||
await sleep(200);
|
||||
}
|
||||
sendKeys('{ENTER}');
|
||||
console.log('Clicked Accept');
|
||||
|
||||
await sleep(3000);
|
||||
await takeScreenshot('oob3.png');
|
||||
|
||||
sendKeys('{ESC}');
|
||||
console.log('Escaped start menu');
|
||||
|
||||
await sleep(3000);
|
||||
await takeScreenshot('oob4.png');
|
||||
|
||||
} else {
|
||||
console.log('NOT running on ARM64');
|
||||
}
|
||||
// Re-execute SSD to open login dialog
|
||||
launchSSD();
|
||||
await sleep(3000);
|
||||
|
||||
@@ -60,6 +60,9 @@ These filters can be used alone or in conjunction with the `matchfield|skipfield
|
||||
[(any|all):]notdata:<DataSelector>|
|
||||
[(any|all):]notregex:<RESearchPattern>|
|
||||
[(any|all):]notregexcs:<RESearchPattern>|
|
||||
[(any|all):]number<Operator><Number>|
|
||||
[(any|all):]numberrange!=<Number>/<Number>|
|
||||
[(any|all):]numberrange=<Number>/<Number>|
|
||||
[(any|all):]regex:<RESearchPattern>|
|
||||
[(any|all):]regexcs:<RESearchPattern>|
|
||||
[(any|all):]text<Operator><String>|
|
||||
|
||||
@@ -68,6 +68,9 @@ on all platforms.
|
||||
[(any|all):]notdata:<DataSelector>
|
||||
[(any|all):]notregex:<RESearchPattern>|
|
||||
[(any|all):]notregexcs:<RESearchPattern>|
|
||||
[(any|all):]number<Operator><Number>|
|
||||
[(any|all):]numberrange!=<Number>/<Number>|
|
||||
[(any|all):]numberrange=<Number>/<Number>|
|
||||
[(any|all):]regex:<RESearchPattern>|
|
||||
[(any|all):]regexcs:<RESearchPattern>|
|
||||
[(any|all):]text<Operator><String>|
|
||||
|
||||
@@ -131,6 +131,7 @@ gam user user@domain.com check|update serviceaccount
|
||||
name|
|
||||
owneremail|
|
||||
ownerid|
|
||||
ownername|
|
||||
room|
|
||||
section|
|
||||
teacherfolder|
|
||||
@@ -431,14 +432,16 @@ gam courses <CourseEntity> update topic <CourseTopicIDEntity> <CourseTopic>
|
||||
|
||||
## Display courses
|
||||
```
|
||||
gam info course <CourseID> [owneremail] [alias|aliases] [show all|students|teachers] [countsonly]
|
||||
gam info course <CourseID>
|
||||
[owneremail] [ownername] [alias|aliases] [show all|students|teachers] [countsonly]
|
||||
[fields <CourseFieldNameList>] [skipfields <CourseFieldNameList>] [formatjson]
|
||||
gam info courses <CourseEntity> [owneremail] [alias|aliases] [show all|students|teachers] [countsonly]
|
||||
gam info courses <CourseEntity>
|
||||
[owneremail] [ownername] [alias|aliases] [show all|students|teachers] [countsonly]
|
||||
[fields <CourseFieldNameList>] [skipfields <CourseFieldNameList>] [formatjson]
|
||||
|
||||
gam print courses [todrive <ToDriveAttribute>*]
|
||||
(course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] [states <CourseStateList>])
|
||||
[owneremail] [owneremailmatchpattern <REMatchPattern>]
|
||||
[owneremail] [owneremailmatchpattern <REMatchPattern>] [ownername]
|
||||
[alias|aliases|aliasesincolumns [delimiter <Character>]]
|
||||
[show all|students|teachers] [countsonly]
|
||||
[timefilter creationtime|updatetime] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>]
|
||||
@@ -471,7 +474,9 @@ To get information about courses created/updated within a particular time frame,
|
||||
For the filter to apply, `timefilter` and at least one of `start|starttime` and `end|endtime` must be specified.
|
||||
|
||||
By default, all basic course fields are displayed; use the following options to modify the output.
|
||||
* `owneremail` - Display course owner email; requires an additional API call per course.
|
||||
* `owneremail` - Display course owner email.
|
||||
* `ownername` - Display course owner name.
|
||||
* These options require an additional API call per course.
|
||||
* `alias|aliases` - Display course aliases; all aliases are in the single column `Aliases` separated by a delimiter; requires an additional API call per course.
|
||||
* `delimiter <Character>` - Delimiter between aliases with `print` command.
|
||||
* `aliasesincolumn` - Display course aliases; the `Aliases` column contains the number of aliases and `Aliases.0`, `Aliases.1`, ... contain the individual aliases; requires an additional API call per course.
|
||||
@@ -525,7 +530,8 @@ gam print course-announcements [todrive <ToDriveAttribute>*]
|
||||
(course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] states <CourseStateList>])
|
||||
(courseannouncementids <CourseAnnouncementIDEntity>)|(announcementstates <CourseAnnouncementStateList>)*
|
||||
(orderby <CourseAnnouncementOrderByFieldName> [ascending|descending])*)
|
||||
[creatoremail] [fields <CourseAnnouncementFieldNameList>]
|
||||
[showcreatoremails|creatoremail] [showcreatornames|creatorname]
|
||||
[fields <CourseAnnouncementFieldNameList>]
|
||||
[timefilter creationtime|updatetime|scheduledtime] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>]
|
||||
[countsonly] [formatjson [quotechar <Character>]]
|
||||
```
|
||||
@@ -553,7 +559,10 @@ To get information about course announcements created/updated/scheduled within a
|
||||
For the filter to apply, `timefilter` and at least one of `start|starttime` and `end|endtime` must be specified.
|
||||
|
||||
By default, all course announcement fields are displayed; use the following options to modify the output.
|
||||
* `creatoremail` - Display course announcement creator email; requires an additional API call per course announcement.
|
||||
* `creatoremail` - Display course announcement creator email.
|
||||
* `creatorname` - Display course announcement creator name.
|
||||
* These options require an additional API call per course.
|
||||
* `alias|aliases` - Display course aliases; all aliases are in the single column `Aliases` separated by a delimiter; requires an additional API call per course.
|
||||
* `fields <CourseAnnouncementFieldNameList>` - Select specific fields to display.
|
||||
|
||||
Use the `countsonly` option to display the number of announcements in a course but not their details.
|
||||
@@ -573,7 +582,8 @@ gam print course-materials [todrive <ToDriveAttribute>*]
|
||||
(course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] states <CourseStateList>])
|
||||
(materialids <CourseMaterialIDEntity>)|(materialstates <CourseMaterialStateList>)*
|
||||
(orderby <CourseMaterialOrderByFieldName> [ascending|descending])*)
|
||||
[showcreatoremails|creatoremail] [showtopicnames] [fields <CourseMaterialFieldNameList>]
|
||||
[showcreatoremails|creatoremail] [showcreatornames|creatorname] [showtopicnames]
|
||||
[fields <CourseMaterialFieldNameList>]
|
||||
[timefilter creationtime|updatetime|scheduledtime] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>]
|
||||
[oneitemperrow]
|
||||
[countsonly] [formatjson [quotechar <Character>]]
|
||||
@@ -602,8 +612,10 @@ By default, all published course materials for a course are displayed; use the f
|
||||
* `materialsstates <CourseMaterialsStateList>` - Display course materials with any of the specified states.
|
||||
|
||||
By default, all course materials fields are displayed; use the following options to modify the output.
|
||||
* `showcreatoremails` - Display course materials creator email; requires an additional API call per course materials.
|
||||
* `showtopicnames` - Display topic names; requires and additional API call per course.
|
||||
* `showcreatoremails|creatoremail` - Display course materials creator email.
|
||||
* `showcreatornames|creatorname` - Display course materials creator name.
|
||||
* These options require an additional API call per course.
|
||||
* `showtopicnames` - Display topic names; requires an additional API call per course.
|
||||
* `fields <CourseMaterialsFieldNameList>` - Select specific fields to display.
|
||||
|
||||
With `print course-materials`, the materials selected for display are all output on one row/line as a repeating item with the other course fields.
|
||||
@@ -669,7 +681,8 @@ gam print course-work [todrive <ToDriveAttribute>*]
|
||||
(course|class <CourseEntity>)*|([teacher <UserItem>] [student <UserItem>] states <CourseStateList>])
|
||||
(workids <CourseWorkIDEntity>)|(workstates <CourseWorkStateList>)*
|
||||
(orderby <CourseWorkOrderByFieldName> [ascending|descending])*)
|
||||
[showcreatoremails] [showtopicnames] [fields <CourseWorkFieldNameList>]
|
||||
[showcreatoremails|creatoremail] [showcreatornames|creatorname] [showtopicnames]
|
||||
[fields <CourseWorkFieldNameList>]
|
||||
[showstudentsaslist [<Boolean>]] [delimiter <Character>]
|
||||
[timefilter creationtime|updatetime|scheduledtime] [start|starttime <Date>|<Time>] [end|endtime <Date>|<Time>]
|
||||
[oneitemperrow]
|
||||
@@ -699,8 +712,10 @@ By default, all published course work for a course is displayed; use the followi
|
||||
* `workstates <CourseWorkStateList>` - Display course work with any of the specified states.
|
||||
|
||||
By default, all course work fields are displayed; use the following options to modify the output.
|
||||
* `showcreatoremails` - Display course work creator email; requires an additional API call per course work.
|
||||
* `showtopicnames` - Display topic names; requires and additional API call per course.
|
||||
* `showcreatoremails|creatoremail` - Display course materials creator email.
|
||||
* `showcreatornames|creatorname` - Display course materials creator name.
|
||||
* These options require an additional API call per course.
|
||||
* `showtopicnames` - Display topic names; requires an additional API call per course.
|
||||
* `fields <CourseWorkFieldNameList>` - Select specific fields to display.
|
||||
|
||||
By default, when course work is assigned to individual students, the student IDs are displayed in multiple indexed columns.
|
||||
|
||||
@@ -36,13 +36,15 @@ gam version
|
||||
Updating from Jay’s GAM to GAM7 may introduce some issues. If your GAM install is critical to your product Google environment you may want to wait for some of the upgrade challenges to be ironed out in the next few weeks. Having said that, GAM stores all of it’s configuration in the GAM install folder so backing up that folder should preserve your old GAM config. For [upgrade instructions see the wiki](https://github.com/GAM-team/GAM/wiki/How-to-upgrade-from-Standard-GAM).
|
||||
|
||||
# What does this mean if I’m using Ross’ GAM-ADV?
|
||||
GAM7 is effectively the same source as GAMADV-XTD3 with minor changes to point to the [github.com/GAM-team/GAM](http://github.com/GAM-team/GAM) site. On Linux/MacOS you should be able to run:
|
||||
GAM7 is effectively the same source as GAMADV-XTD3 with minor changes to point to the [github.com/GAM-team/GAM](http://github.com/GAM-team/GAM) site.
|
||||
|
||||
On Linux/MacOS you should be able to run:
|
||||
|
||||
```
|
||||
bash <(curl -s -S -L https://git.io/gam-install) -l
|
||||
```
|
||||
|
||||
To upgrade. On Windows, download the latest MSI from [git.io/gam-releases](http://git.io/gam-releases) and install it to the same path you had GAM-ADV installed.
|
||||
On Windows, download the latest EXE installer from [git.io/gam-releases](http://git.io/gam-releases), run it and install it to the same path you had GAM-ADV installed.
|
||||
|
||||
Both GAM7 and GAM-ADV versions use the same configuration file (gam.cfg), and credentials; they are interchangeable.
|
||||
|
||||
|
||||
@@ -10,6 +10,50 @@ Add the `-s` option to the end of the above commands to suppress creating the `g
|
||||
|
||||
See [Downloads-Installs-GAM7](https://github.com/GAM-team/GAM/wiki/Downloads-Installs) for Windows or other options, including manual installation
|
||||
|
||||
### 7.41.02
|
||||
|
||||
Added option `ownername` to `gam info|print courses` to have GAM display the course owners full name;
|
||||
there is an extra API call per course to get the name.
|
||||
|
||||
Added option `creatorname` to `gam print course-announcements|course-materials|course-works` to have
|
||||
GAM display the item creators full name; there is an extra API call per course to get the name.
|
||||
|
||||
After creating a group, it may be sometime, e.g. 30-45 seconds, before members can
|
||||
successfully be added to the group even though the API reported that the group was created.
|
||||
The following options can be used with `gam create group` to verify that the group is actually ready to be updated.
|
||||
This will be most useful in scripts that are used to create and then populate groups.
|
||||
```
|
||||
verifycreationretries <Integer> - Verify group creation, defaults to 0, no verification performed, range 0-20
|
||||
verifycreationinitialdelay <Integer> - Number of seconds to delay before first verification performed, defaults to 5, range 0-60
|
||||
verifycreationretrydelay <Integer> - Number of seconds to delay between verificaton retries, defaults to 5, range 1-60
|
||||
```
|
||||
|
||||
If you have a script that deletes a group and then immediately tries to create a new group with the same email address,
|
||||
you may run into issues. There seems to be a 30-45 second window after the deletion in which a couple
|
||||
of strange errors can occur on the creation: `Resource not found` and `Duplicate`.
|
||||
The following options can be used with `gam create group` to handle these errors. This will be most useful
|
||||
in scripts that are used to delete and then immediately recreate groups.
|
||||
```
|
||||
recentdeleteretries <Integer> - Handle group delete/create errors, defaults to 0, no errors handled, range 0-20
|
||||
recentdeleteretrydelay <Integer> - Number of seconds to delay between retries, defaults to 5, range 1-60
|
||||
```
|
||||
|
||||
Added the following to `<RowValueFilter>` used in CSV input/output row filtering; these are
|
||||
synonyms for `count` and `countrange`.
|
||||
```
|
||||
[(any|all):]number<Operator><Number>|
|
||||
[(any|all):]numberrange!=<Number>/<Number>|
|
||||
[(any|all):]numberrange=<Number>/<Number>|
|
||||
```
|
||||
|
||||
### 7.41.01
|
||||
|
||||
Fixed bug in `gam print cigroups members managers owners countsonly totalcount internal external` that caused a trap.
|
||||
|
||||
### 7.41.00
|
||||
|
||||
Upgraded to Python 3.14.4 and OpenSSL 4.0.0.
|
||||
|
||||
### 7.40.03
|
||||
|
||||
Added option `whocanaddexternalmembers only_owners_can_add_external_members|end_users_can_add_external_members` to `<GroupSettingsAttribute>`.
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
- [GUI API Group access type settings mapping](#gui-api-group-access-type-settings-mapping)
|
||||
- [whoCanViewMembership and whoCanDiscoverGroup interactions](#whocanviewmembership-and-whocandiscovergroup-interactions)
|
||||
- [Manage groups](#manage-groups)
|
||||
- [Handle group deletion and immediate recreation](#handle-group-deletion-and-immediate-recreation)
|
||||
- [Verify group creation](#verify-group-creation)
|
||||
- [Update a group's primary email address](#update-a-groups-primary-email-address)
|
||||
- [Update a group's settings with JSON data](#update-a-groups-settings-with-json-data)
|
||||
- [Display information about specific groups](#display-information-about-specific-groups)
|
||||
@@ -350,6 +352,8 @@ These commands allow you to create, update and delete groups.
|
||||
gam create group <EmailAddress>
|
||||
[copyfrom <GroupItem>] <GroupAttribute>*
|
||||
[verifynotinvitable]
|
||||
[recentdeleteretries <Integer>] [recentdeleteretrydelay <Integer>]
|
||||
[verifycreationretries <Integer>] [verifycreationinitialdelay <Integer>] [verifycreationretrydelay <Integer>]
|
||||
gam update group|groups <GroupEntity> [email <EmailAddress>]
|
||||
[updateprimaryemail <RESearchPattern> <RESubstitution> [preview]]
|
||||
[copyfrom <GroupItem>] <GroupAttribute>*
|
||||
@@ -367,6 +371,28 @@ You can update a group to a security group with the `makesecuritygroup` option.
|
||||
|
||||
When deleting and `noactionifalias` is specified, no action is performed if `<GroupEntity>` specifies an alias rather than a primary email address.
|
||||
|
||||
## Handle group deletion and immediate recreation
|
||||
If you have a script that deletes a group and then immediately tries to create a new group with the same email address,
|
||||
you may run into issues. There seems to be a 30-45 second window after the deletion in which a couple
|
||||
of strange errors can occur on the creation: `Resource not found` and `Duplicate`.
|
||||
The following options can be used with `gam create group` to handle these errors. This will be most useful
|
||||
in scripts that are used to delete and then immediately recreate groups.
|
||||
```
|
||||
recentdeleteretries <Integer> - Handle group delete/create errors, defaults to 0, no errors handled, range 0-20
|
||||
recentdeleteretrydelay <Integer> - Number of seconds to delay between retries, defaults to 5, range 1-60
|
||||
```
|
||||
|
||||
## Verify group creation
|
||||
After creating a group, it may be sometime, e.g. 30-45 seconds, before members can
|
||||
successfully be added to the group even though the API reported that the group was created.
|
||||
The following options can be used with `gam create group` to verify that the group is actually ready to be updated.
|
||||
This will be most useful in scripts that are used to create and then populate groups.
|
||||
```
|
||||
verifycreationretries <Integer> - Verify group creation, defaults to 0, no verification performed, range 0-20
|
||||
verifycreationinitialdelay <Integer> - Number of seconds to delay before first verification performed, defaults to 5, range 0-60
|
||||
verifycreationretrydelay <Integer> - Number of seconds to delay between verificaton retries, defaults to 5, range 1-60
|
||||
```
|
||||
|
||||
## Update a group's primary email address
|
||||
You can simply update a group's primary email address with the `email` option.
|
||||
```
|
||||
|
||||
@@ -251,7 +251,7 @@ writes the credentials into the file oauth2.txt.
|
||||
```
|
||||
gamteam@server:/Users/gamteam$ rm -f /Users/gamteam/GAMConfig/oauth2.txt
|
||||
gamteam@server:/Users/gamteam$ gam version
|
||||
GAM 7.40.03 - https://github.com/GAM-team/GAM - pyinstaller
|
||||
GAM 7.41.02 - https://github.com/GAM-team/GAM - pyinstaller
|
||||
GAM Team <google-apps-manager@googlegroups.com>
|
||||
Python 3.14.4 64-bit final
|
||||
macOS Tahoe 26.4.1 arm64
|
||||
@@ -1034,7 +1034,7 @@ writes the credentials into the file oauth2.txt.
|
||||
```
|
||||
C:\>del C:\GAMConfig\oauth2.txt
|
||||
C:\>gam version
|
||||
GAM 7.40.03 - https://github.com/GAM-team/GAM - pythonsource
|
||||
GAM 7.41.02 - https://github.com/GAM-team/GAM - pythonsource
|
||||
GAM Team <google-apps-manager@googlegroups.com>
|
||||
Python 3.14.4 64-bit final
|
||||
Windows 11 10.0.26200 AMD64
|
||||
|
||||
@@ -132,7 +132,7 @@ See: [Users - Calendars - Transfer](Users-Calendars-Transfer)
|
||||
|
||||
GAM7 supports domain shared contacts and user contacts.
|
||||
|
||||
See: [Domain Shared Contacts](Contacts)
|
||||
See: [Domain Shared Contacts](Domain-SharedContacts)
|
||||
|
||||
See: [Users - People - Contacts & Profiles](Users-People-Contacts-Profiles)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Print the current version of Gam with details
|
||||
```
|
||||
gam version
|
||||
GAM 7.40.03 - https://github.com/GAM-team/GAM - pyinstaller
|
||||
GAM 7.41.02 - https://github.com/GAM-team/GAM - pyinstaller
|
||||
GAM Team <google-apps-manager@googlegroups.com>
|
||||
Python 3.14.4 64-bit final
|
||||
macOS Tahoe 26.4.1 arm64
|
||||
@@ -15,7 +15,7 @@ Time: 2026-02-15T07:51:00-08:00
|
||||
Print the current version of Gam with details and time offset information
|
||||
```
|
||||
gam version timeoffset
|
||||
GAM 7.40.03 - https://github.com/GAM-team/GAM - pyinstaller
|
||||
GAM 7.41.02 - https://github.com/GAM-team/GAM - pyinstaller
|
||||
GAM Team <google-apps-manager@googlegroups.com>
|
||||
Python 3.14.4 64-bit final
|
||||
macOS Tahoe 26.4.1 arm64
|
||||
@@ -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
|
||||
GAM 7.40.03 - https://github.com/GAM-team/GAM - pyinstaller
|
||||
GAM 7.41.02 - https://github.com/GAM-team/GAM - pyinstaller
|
||||
GAM Team <google-apps-manager@googlegroups.com>
|
||||
Python 3.14.4 64-bit final
|
||||
macOS Tahoe 26.4.1 arm64
|
||||
@@ -35,7 +35,7 @@ Path: /Users/gamteam/bin/gam7
|
||||
Config File: /Users/gamteam/GamConfig/gam.cfg, Section: DEFAULT, customer_id: my_customer, domain: domain.com
|
||||
Time: 2026-02-15T07:51:00-08:00
|
||||
Your system time differs from admin.googleapis.com by less than 1 second
|
||||
OpenSSL 3.6.2 7 Apr 2026
|
||||
OpenSSL 4.0.0 14 Apr 2026
|
||||
arrow 1.4.0
|
||||
chardet 5.2.0
|
||||
cryptography 46.0.5
|
||||
@@ -68,7 +68,7 @@ MacOS High Sierra 10.13.6 x86_64
|
||||
Path: /Users/gamteam/bin/gam7
|
||||
Version Check:
|
||||
Current: 5.35.08
|
||||
Latest: 7.40.03
|
||||
Latest: 7.41.02
|
||||
echo $?
|
||||
1
|
||||
```
|
||||
@@ -76,7 +76,7 @@ echo $?
|
||||
Print the current version number without details
|
||||
```
|
||||
gam version simple
|
||||
7.40.03
|
||||
7.41.02
|
||||
```
|
||||
In Linux/MacOS you can do:
|
||||
```
|
||||
@@ -86,7 +86,7 @@ echo $VER
|
||||
Print the current version of Gam and address of this Wiki
|
||||
```
|
||||
gam help
|
||||
GAM 7.40.03 - https://github.com/GAM-team/GAM
|
||||
GAM 7.41.02 - https://github.com/GAM-team/GAM
|
||||
GAM Team <google-apps-manager@googlegroups.com>
|
||||
Python 3.14.4 64-bit final
|
||||
macOS Tahoe 26.4.1 arm64
|
||||
|
||||
Reference in New Issue
Block a user