diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2eab7d566..d1cb690dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -62,7 +62,7 @@ jobs: fetch-depth: 1 fetch-tags: false - - name: Get Next Version + - name: Get Next Version (Prod) if: ${{ github.ref_name == 'release' }} id: semver uses: ietf-tools/semver-action@v1 @@ -70,11 +70,22 @@ jobs: token: ${{ github.token }} branch: release skipInvalidTags: true - - - name: Set Next Version Env Var + + - name: Get Dev Version + if: ${{ github.ref_name != 'release' }} + id: semverdev + uses: ietf-tools/semver-action@v1 + with: + token: ${{ github.token }} + branch: release + skipInvalidTags: true + noVersionBumpBehavior: 'current' + noNewCommitBehavior: 'current' + + - name: Set Release Flag if: ${{ github.ref_name == 'release' }} run: | - echo "NEXT_VERSION=$nextStrict" >> $GITHUB_ENV + echo "IS_RELEASE=true" >> $GITHUB_ENV - name: Create Draft Release uses: ncipollo/release-action@v1.14.0 @@ -83,24 +94,24 @@ jobs: prerelease: true draft: false commit: ${{ github.sha }} - tag: ${{ env.NEXT_VERSION }} - name: ${{ env.NEXT_VERSION }} + tag: ${{ steps.semver.outputs.nextStrict }} + name: ${{ steps.semver.outputs.nextStrict }} body: '*pending*' token: ${{ secrets.GITHUB_TOKEN }} - name: Set Build Variables id: buildvars run: | - if [[ $NEXT_VERSION ]]; then - echo "Using AUTO SEMVER mode: $NEXT_VERSION" + if [[ $IS_RELEASE ]]; then + echo "Using AUTO SEMVER mode: ${{ steps.semver.outputs.nextStrict }}" echo "should_deploy=true" >> $GITHUB_OUTPUT - echo "pkg_version=$NEXT_VERSION" >> $GITHUB_OUTPUT - echo "::notice::Release $NEXT_VERSION created using branch $GITHUB_REF_NAME" + echo "pkg_version=${{ steps.semver.outputs.nextStrict }}" >> $GITHUB_OUTPUT + echo "::notice::Release ${{ steps.semver.outputs.nextStrict }} created using branch $GITHUB_REF_NAME" else - echo "Using TEST mode: 11.0.0-dev.$GITHUB_RUN_NUMBER" + echo "Using TEST mode: ${{ steps.semverdev.outputs.nextMajorStrict }}.0.0-dev.$GITHUB_RUN_NUMBER" echo "should_deploy=false" >> $GITHUB_OUTPUT - echo "pkg_version=11.0.0-dev.$GITHUB_RUN_NUMBER" >> $GITHUB_OUTPUT - echo "::notice::Non-production build 11.0.0-dev.$GITHUB_RUN_NUMBER created using branch $GITHUB_REF_NAME" + echo "pkg_version=${{ steps.semverdev.outputs.nextMajorStrict }}.0.0-dev.$GITHUB_RUN_NUMBER" >> $GITHUB_OUTPUT + echo "::notice::Non-production build ${{ steps.semverdev.outputs.nextMajorStrict }}.0.0-dev.$GITHUB_RUN_NUMBER created using branch $GITHUB_REF_NAME" fi # ----------------------------------------------------------------- diff --git a/.pnp.cjs b/.pnp.cjs index 3d516ee80..241319adb 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -54,7 +54,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["browser-fs-access", "npm:0.35.0"],\ ["browserlist", "npm:1.0.1"],\ ["c8", "npm:9.1.0"],\ - ["caniuse-lite", "npm:1.0.30001593"],\ + ["caniuse-lite", "npm:1.0.30001597"],\ ["d3", "npm:7.8.5"],\ ["eslint", "npm:8.57.0"],\ ["eslint-config-standard", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:17.1.0"],\ @@ -65,8 +65,8 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["eslint-plugin-promise", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.1"],\ ["eslint-plugin-vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:9.22.0"],\ ["file-saver", "npm:2.0.5"],\ - ["highcharts", "npm:11.3.0"],\ - ["html-validate", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:8.12.0"],\ + ["highcharts", "npm:11.4.0"],\ + ["html-validate", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:8.15.0"],\ ["ical.js", "npm:1.5.0"],\ ["jquery", "npm:3.7.1"],\ ["jquery-migrate", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.1"],\ @@ -2431,10 +2431,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:8610821c5e1e51c5c5dd69b04c702b6346ee6c1bf2a4ae51e6f8e83cab57f22f2adcccaceefc54eea7a8a65ad6b6f37bc97e0a24ce478bc46010609f3b800357#npm:2.1.3", {\ - "packageLocation": "./.yarn/__virtual__/@sidvind-better-ajv-errors-virtual-52941d62ff/0/cache/@sidvind-better-ajv-errors-npm-2.1.3-e3d1c524a8-949cb805a1.zip/node_modules/@sidvind/better-ajv-errors/",\ + ["virtual:2a2a921469e6f0bfdb6b2bd79f75a3395d47a481854507365048f3d989418f207cf814cb2ce1a012d2da774c1d130b4ca418582463ec08381da55e543b959c4c#npm:2.1.3", {\ + "packageLocation": "./.yarn/__virtual__/@sidvind-better-ajv-errors-virtual-6ac4a81dfc/0/cache/@sidvind-better-ajv-errors-npm-2.1.3-e3d1c524a8-949cb805a1.zip/node_modules/@sidvind/better-ajv-errors/",\ "packageDependencies": [\ - ["@sidvind/better-ajv-errors", "virtual:8610821c5e1e51c5c5dd69b04c702b6346ee6c1bf2a4ae51e6f8e83cab57f22f2adcccaceefc54eea7a8a65ad6b6f37bc97e0a24ce478bc46010609f3b800357#npm:2.1.3"],\ + ["@sidvind/better-ajv-errors", "virtual:2a2a921469e6f0bfdb6b2bd79f75a3395d47a481854507365048f3d989418f207cf814cb2ce1a012d2da774c1d130b4ca418582463ec08381da55e543b959c4c#npm:2.1.3"],\ ["@babel/code-frame", "npm:7.16.7"],\ ["@types/ajv", null],\ ["ajv", "npm:8.11.0"],\ @@ -3452,10 +3452,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "HARD"\ }],\ - ["npm:1.0.30001593", {\ - "packageLocation": "./.yarn/cache/caniuse-lite-npm-1.0.30001593-bb646b1090-3e2b190755.zip/node_modules/caniuse-lite/",\ + ["npm:1.0.30001597", {\ + "packageLocation": "./.yarn/cache/caniuse-lite-npm-1.0.30001597-1e349680d5-ec6a2cf0fd.zip/node_modules/caniuse-lite/",\ "packageDependencies": [\ - ["caniuse-lite", "npm:1.0.30001593"]\ + ["caniuse-lite", "npm:1.0.30001597"]\ ],\ "linkType": "HARD"\ }]\ @@ -5747,10 +5747,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["highcharts", [\ - ["npm:11.3.0", {\ - "packageLocation": "./.yarn/cache/highcharts-npm-11.3.0-d9aa04531f-6511a004f7.zip/node_modules/highcharts/",\ + ["npm:11.4.0", {\ + "packageLocation": "./.yarn/cache/highcharts-npm-11.4.0-8a1f46b545-873e661914.zip/node_modules/highcharts/",\ "packageDependencies": [\ - ["highcharts", "npm:11.3.0"]\ + ["highcharts", "npm:11.4.0"]\ ],\ "linkType": "HARD"\ }]\ @@ -5774,20 +5774,20 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["html-validate", [\ - ["npm:8.12.0", {\ - "packageLocation": "./.yarn/cache/html-validate-npm-8.12.0-a271026de7-4979a70085.zip/node_modules/html-validate/",\ + ["npm:8.15.0", {\ + "packageLocation": "./.yarn/cache/html-validate-npm-8.15.0-a1dfa4198d-0af7685ca1.zip/node_modules/html-validate/",\ "packageDependencies": [\ - ["html-validate", "npm:8.12.0"]\ + ["html-validate", "npm:8.15.0"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:8.12.0", {\ - "packageLocation": "./.yarn/__virtual__/html-validate-virtual-8610821c5e/0/cache/html-validate-npm-8.12.0-a271026de7-4979a70085.zip/node_modules/html-validate/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:8.15.0", {\ + "packageLocation": "./.yarn/__virtual__/html-validate-virtual-2a2a921469/0/cache/html-validate-npm-8.15.0-a1dfa4198d-0af7685ca1.zip/node_modules/html-validate/",\ "packageDependencies": [\ - ["html-validate", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:8.12.0"],\ + ["html-validate", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:8.15.0"],\ ["@babel/code-frame", "npm:7.16.7"],\ ["@html-validate/stylish", "npm:4.1.0"],\ - ["@sidvind/better-ajv-errors", "virtual:8610821c5e1e51c5c5dd69b04c702b6346ee6c1bf2a4ae51e6f8e83cab57f22f2adcccaceefc54eea7a8a65ad6b6f37bc97e0a24ce478bc46010609f3b800357#npm:2.1.3"],\ + ["@sidvind/better-ajv-errors", "virtual:2a2a921469e6f0bfdb6b2bd79f75a3395d47a481854507365048f3d989418f207cf814cb2ce1a012d2da774c1d130b4ca418582463ec08381da55e543b959c4c#npm:2.1.3"],\ ["@types/jest", null],\ ["@types/jest-diff", null],\ ["@types/jest-snapshot", null],\ @@ -8274,7 +8274,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["browser-fs-access", "npm:0.35.0"],\ ["browserlist", "npm:1.0.1"],\ ["c8", "npm:9.1.0"],\ - ["caniuse-lite", "npm:1.0.30001593"],\ + ["caniuse-lite", "npm:1.0.30001597"],\ ["d3", "npm:7.8.5"],\ ["eslint", "npm:8.57.0"],\ ["eslint-config-standard", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:17.1.0"],\ @@ -8285,8 +8285,8 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["eslint-plugin-promise", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.1"],\ ["eslint-plugin-vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:9.22.0"],\ ["file-saver", "npm:2.0.5"],\ - ["highcharts", "npm:11.3.0"],\ - ["html-validate", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:8.12.0"],\ + ["highcharts", "npm:11.4.0"],\ + ["html-validate", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:8.15.0"],\ ["ical.js", "npm:1.5.0"],\ ["jquery", "npm:3.7.1"],\ ["jquery-migrate", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.1"],\ diff --git a/.yarn/cache/caniuse-lite-npm-1.0.30001593-bb646b1090-3e2b190755.zip b/.yarn/cache/caniuse-lite-npm-1.0.30001597-1e349680d5-ec6a2cf0fd.zip similarity index 91% rename from .yarn/cache/caniuse-lite-npm-1.0.30001593-bb646b1090-3e2b190755.zip rename to .yarn/cache/caniuse-lite-npm-1.0.30001597-1e349680d5-ec6a2cf0fd.zip index a6ea1c671..f1545a78f 100644 Binary files a/.yarn/cache/caniuse-lite-npm-1.0.30001593-bb646b1090-3e2b190755.zip and b/.yarn/cache/caniuse-lite-npm-1.0.30001597-1e349680d5-ec6a2cf0fd.zip differ diff --git a/.yarn/cache/highcharts-npm-11.3.0-d9aa04531f-6511a004f7.zip b/.yarn/cache/highcharts-npm-11.4.0-8a1f46b545-873e661914.zip similarity index 59% rename from .yarn/cache/highcharts-npm-11.3.0-d9aa04531f-6511a004f7.zip rename to .yarn/cache/highcharts-npm-11.4.0-8a1f46b545-873e661914.zip index 2ec522d76..9c2f2df15 100644 Binary files a/.yarn/cache/highcharts-npm-11.3.0-d9aa04531f-6511a004f7.zip and b/.yarn/cache/highcharts-npm-11.4.0-8a1f46b545-873e661914.zip differ diff --git a/.yarn/cache/html-validate-npm-8.12.0-a271026de7-4979a70085.zip b/.yarn/cache/html-validate-npm-8.12.0-a271026de7-4979a70085.zip deleted file mode 100644 index 8b4ea432e..000000000 Binary files a/.yarn/cache/html-validate-npm-8.12.0-a271026de7-4979a70085.zip and /dev/null differ diff --git a/.yarn/cache/html-validate-npm-8.15.0-a1dfa4198d-0af7685ca1.zip b/.yarn/cache/html-validate-npm-8.15.0-a1dfa4198d-0af7685ca1.zip new file mode 100644 index 000000000..fed180abd Binary files /dev/null and b/.yarn/cache/html-validate-npm-8.15.0-a1dfa4198d-0af7685ca1.zip differ diff --git a/ietf/__init__.py b/ietf/__init__.py index 59f9802de..26124c3c6 100644 --- a/ietf/__init__.py +++ b/ietf/__init__.py @@ -6,7 +6,7 @@ from . import checks # pyflakes:ignore # Version must stay in single quotes for automatic CI replace # Don't add patch number here: -__version__ = '11.0.0-dev' +__version__ = '1.0.0-dev' # Release hash must stay in single quotes for automatic CI replace __release_hash__ = '' @@ -17,6 +17,24 @@ __release_branch__ = '' # set this to ".p1", ".p2", etc. after patching __patch__ = "" +if __version__ == '1.0.0-dev' and __release_hash__ == '' and __release_branch__ == '': + import subprocess + branch = subprocess.run( + ["/usr/bin/git", "branch", "--show-current"], + capture_output=True, + ).stdout.decode().strip() + git_hash = subprocess.run( + ["/usr/bin/git", "rev-parse", "head"], + capture_output=True, + ).stdout.decode().strip() + rev = subprocess.run( + ["/usr/bin/git", "describe", "--tags", git_hash], + capture_output=True, + ).stdout.decode().strip().split('-', 1)[0] + __version__ = f"{rev}-dev" + __release_branch__ = branch + __release_hash__ = git_hash + # This will make sure the app is always imported when # Django starts so that shared_task will use this app. diff --git a/ietf/api/tests.py b/ietf/api/tests.py index 4fc1d46cd..2310d71d7 100644 --- a/ietf/api/tests.py +++ b/ietf/api/tests.py @@ -219,7 +219,9 @@ class CustomApiTests(TestCase): event = doc.latest_event() self.assertEqual(event.by, recman) - def test_api_add_session_attendees(self): + def test_api_add_session_attendees_deprecated(self): + # Deprecated test - should be removed when we stop accepting a simple list of user PKs in + # the add_session_attendees() view url = urlreverse('ietf.meeting.views.api_add_session_attendees') otherperson = PersonFactory() recmanrole = RoleFactory(group__type_id='ietf', name_id='recman') @@ -285,6 +287,120 @@ class CustomApiTests(TestCase): self.assertTrue(session.attended_set.filter(person=recman).exists()) self.assertTrue(session.attended_set.filter(person=otherperson).exists()) + def test_api_add_session_attendees(self): + url = urlreverse("ietf.meeting.views.api_add_session_attendees") + otherperson = PersonFactory() + recmanrole = RoleFactory(group__type_id="ietf", name_id="recman") + recman = recmanrole.person + meeting = MeetingFactory(type_id="ietf") + session = SessionFactory(group__type_id="wg", meeting=meeting) + apikey = PersonalApiKey.objects.create(endpoint=url, person=recman) + + badrole = RoleFactory(group__type_id="ietf", name_id="ad") + badapikey = PersonalApiKey.objects.create(endpoint=url, person=badrole.person) + badrole.person.user.last_login = timezone.now() + badrole.person.user.save() + + # Improper credentials, or method + r = self.client.post(url, {}) + self.assertContains(r, "Missing apikey parameter", status_code=400) + + r = self.client.post(url, {"apikey": badapikey.hash()}) + self.assertContains(r, "Restricted to role: Recording Manager", status_code=403) + + r = self.client.post(url, {"apikey": apikey.hash()}) + self.assertContains(r, "Too long since last regular login", status_code=400) + + recman.user.last_login = timezone.now() - datetime.timedelta(days=365) + recman.user.save() + r = self.client.post(url, {"apikey": apikey.hash()}) + self.assertContains(r, "Too long since last regular login", status_code=400) + + recman.user.last_login = timezone.now() + recman.user.save() + r = self.client.get(url, {"apikey": apikey.hash()}) + self.assertContains(r, "Method not allowed", status_code=405) + + recman.user.last_login = timezone.now() + recman.user.save() + + # Malformed requests + r = self.client.post(url, {"apikey": apikey.hash()}) + self.assertContains(r, "Missing attended parameter", status_code=400) + + for baddict in ( + "{}", + '{"bogons;drop table":"bogons;drop table"}', + '{"session_id":"Not an integer;drop table"}', + f'{{"session_id":{session.pk},"attendees":"not a list;drop table"}}', + f'{{"session_id":{session.pk},"attendees":"not a list;drop table"}}', + f'{{"session_id":{session.pk},"attendees":[1,2,"not an int;drop table",4]}}', + f'{{"session_id":{session.pk},"attendees":["user_id":{recman.user.pk}]}}', # no join_time + f'{{"session_id":{session.pk},"attendees":["user_id":{recman.user.pk},"join_time;drop table":"2024-01-01T00:00:00Z]}}', + f'{{"session_id":{session.pk},"attendees":["user_id":{recman.user.pk},"join_time":"not a time;drop table"]}}', + # next has no time zone indicator + f'{{"session_id":{session.pk},"attendees":["user_id":{recman.user.pk},"join_time":"2024-01-01T00:00:00"]}}', + f'{{"session_id":{session.pk},"attendees":["user_id":"not an int; drop table","join_time":"2024-01-01T00:00:00Z"]}}', + # Uncomment the next one when the _deprecated version of this test is retired + # f'{{"session_id":{session.pk},"attendees":[{recman.user.pk}, {otherperson.user.pk}]}}', + ): + r = self.client.post(url, {"apikey": apikey.hash(), "attended": baddict}) + self.assertContains(r, "Malformed post", status_code=400) + + bad_session_id = Session.objects.order_by("-pk").first().pk + 1 + r = self.client.post( + url, + { + "apikey": apikey.hash(), + "attended": f'{{"session_id":{bad_session_id},"attendees":[]}}', + }, + ) + self.assertContains(r, "Invalid session", status_code=400) + bad_user_id = User.objects.order_by("-pk").first().pk + 1 + r = self.client.post( + url, + { + "apikey": apikey.hash(), + "attended": f'{{"session_id":{session.pk},"attendees":[{{"user_id":{bad_user_id}, "join_time":"2024-01-01T00:00:00Z"}}]}}', + }, + ) + self.assertContains(r, "Invalid attendee", status_code=400) + + # Reasonable request + r = self.client.post( + url, + { + "apikey": apikey.hash(), + "attended": json.dumps( + { + "session_id": session.pk, + "attendees": [ + { + "user_id": recman.user.pk, + "join_time": "2023-09-03T12:34:56Z", + }, + { + "user_id": otherperson.user.pk, + "join_time": "2023-09-03T03:00:19Z", + }, + ], + } + ), + }, + ) + + self.assertEqual(session.attended_set.count(), 2) + self.assertTrue(session.attended_set.filter(person=recman).exists()) + self.assertEqual( + session.attended_set.get(person=recman).time, + datetime.datetime(2023, 9, 3, 12, 34, 56, tzinfo=datetime.timezone.utc), + ) + self.assertTrue(session.attended_set.filter(person=otherperson).exists()) + self.assertEqual( + session.attended_set.get(person=otherperson).time, + datetime.datetime(2023, 9, 3, 3, 0, 19, tzinfo=datetime.timezone.utc), + ) + def test_api_upload_polls_and_chatlog(self): recmanrole = RoleFactory(group__type_id='ietf', name_id='recman') recmanrole.person.user.last_login = timezone.now() diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py index c9e1c041d..0ad26b7ad 100644 --- a/ietf/doc/tests.py +++ b/ietf/doc/tests.py @@ -2594,37 +2594,68 @@ class DocumentMeetingTests(TestCase): self.assertFalse(q("#futuremeets a.btn:contains('Remove document')")) self.assertFalse(q("#pastmeets a.btn:contains('Remove document')")) - def test_edit_document_session(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") + @mock.patch("ietf.doc.views_doc.SlidesManager") + def test_edit_document_session(self, mock_slides_manager_cls): doc = IndividualDraftFactory.create() sp = doc.presentations.create(session=self.future,rev=None) url = urlreverse('ietf.doc.views_doc.edit_sessionpresentation',kwargs=dict(name='no-such-doc',session_id=sp.session_id)) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) url = urlreverse('ietf.doc.views_doc.edit_sessionpresentation',kwargs=dict(name=doc.name,session_id=0)) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) url = urlreverse('ietf.doc.views_doc.edit_sessionpresentation',kwargs=dict(name=doc.name,session_id=sp.session_id)) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) self.client.login(username=self.other_chair.user.username,password='%s+password'%self.other_chair.user.username) response = self.client.get(url) self.assertEqual(response.status_code, 404) - + self.assertFalse(mock_slides_manager_cls.called) + self.client.login(username=self.group_chair.user.username,password='%s+password'%self.group_chair.user.username) response = self.client.get(url) self.assertEqual(response.status_code, 200) q = PyQuery(response.content) self.assertEqual(2,len(q('select#id_version option'))) + self.assertFalse(mock_slides_manager_cls.called) + # edit draft self.assertEqual(1,doc.docevent_set.count()) response = self.client.post(url,{'version':'00','save':''}) self.assertEqual(response.status_code, 302) self.assertEqual(doc.presentations.get(pk=sp.pk).rev,'00') self.assertEqual(2,doc.docevent_set.count()) + self.assertFalse(mock_slides_manager_cls.called) + + # editing slides should call Meetecho API + slides = SessionPresentationFactory( + session=self.future, + document__type_id="slides", + document__rev="00", + rev=None, + order=1, + ).document + url = urlreverse( + "ietf.doc.views_doc.edit_sessionpresentation", + kwargs={"name": slides.name, "session_id": self.future.pk}, + ) + response = self.client.post(url, {"version": "00", "save": ""}) + self.assertEqual(response.status_code, 302) + self.assertEqual(mock_slides_manager_cls.call_count, 1) + self.assertEqual(mock_slides_manager_cls.call_args, mock.call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.send_update.call_args, + mock.call(self.future), + ) def test_edit_document_session_after_proceedings_closed(self): doc = IndividualDraftFactory.create() @@ -2641,35 +2672,60 @@ class DocumentMeetingTests(TestCase): q=PyQuery(response.content) self.assertEqual(1,len(q(".alert-warning:contains('may affect published proceedings')"))) - def test_remove_document_session(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") + @mock.patch("ietf.doc.views_doc.SlidesManager") + def test_remove_document_session(self, mock_slides_manager_cls): doc = IndividualDraftFactory.create() sp = doc.presentations.create(session=self.future,rev=None) url = urlreverse('ietf.doc.views_doc.remove_sessionpresentation',kwargs=dict(name='no-such-doc',session_id=sp.session_id)) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) url = urlreverse('ietf.doc.views_doc.remove_sessionpresentation',kwargs=dict(name=doc.name,session_id=0)) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) url = urlreverse('ietf.doc.views_doc.remove_sessionpresentation',kwargs=dict(name=doc.name,session_id=sp.session_id)) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) self.client.login(username=self.other_chair.user.username,password='%s+password'%self.other_chair.user.username) response = self.client.get(url) self.assertEqual(response.status_code, 404) - + self.assertFalse(mock_slides_manager_cls.called) + self.client.login(username=self.group_chair.user.username,password='%s+password'%self.group_chair.user.username) response = self.client.get(url) self.assertEqual(response.status_code, 200) + self.assertFalse(mock_slides_manager_cls.called) + # removing a draft self.assertEqual(1,doc.docevent_set.count()) response = self.client.post(url,{'remove_session':''}) self.assertEqual(response.status_code, 302) self.assertFalse(doc.presentations.filter(pk=sp.pk).exists()) self.assertEqual(2,doc.docevent_set.count()) + self.assertFalse(mock_slides_manager_cls.called) + + # removing slides should call Meetecho API + slides = SessionPresentationFactory(session=self.future, document__type_id="slides", order=1).document + url = urlreverse( + "ietf.doc.views_doc.remove_sessionpresentation", + kwargs={"name": slides.name, "session_id": self.future.pk}, + ) + response = self.client.post(url, {"remove_session": ""}) + self.assertEqual(response.status_code, 302) + self.assertEqual(mock_slides_manager_cls.call_count, 1) + self.assertEqual(mock_slides_manager_cls.call_args, mock.call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.delete.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.delete.call_args, + mock.call(self.future, slides), + ) def test_remove_document_session_after_proceedings_closed(self): doc = IndividualDraftFactory.create() @@ -2686,28 +2742,49 @@ class DocumentMeetingTests(TestCase): q=PyQuery(response.content) self.assertEqual(1,len(q(".alert-warning:contains('may affect published proceedings')"))) - def test_add_document_session(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") + @mock.patch("ietf.doc.views_doc.SlidesManager") + def test_add_document_session(self, mock_slides_manager_cls): doc = IndividualDraftFactory.create() url = urlreverse('ietf.doc.views_doc.add_sessionpresentation',kwargs=dict(name=doc.name)) login_testing_unauthorized(self,self.group_chair.user.username,url) response = self.client.get(url) self.assertEqual(response.status_code,200) - + self.assertFalse(mock_slides_manager_cls.called) + response = self.client.post(url,{'session':0,'version':'current'}) self.assertEqual(response.status_code,200) q=PyQuery(response.content) self.assertTrue(q('.form-select.is-invalid')) + self.assertFalse(mock_slides_manager_cls.called) response = self.client.post(url,{'session':self.future.pk,'version':'bogus version'}) self.assertEqual(response.status_code,200) q=PyQuery(response.content) self.assertTrue(q('.form-select.is-invalid')) + self.assertFalse(mock_slides_manager_cls.called) + # adding a draft self.assertEqual(1,doc.docevent_set.count()) response = self.client.post(url,{'session':self.future.pk,'version':'current'}) self.assertEqual(response.status_code,302) self.assertEqual(2,doc.docevent_set.count()) + self.assertEqual(doc.presentations.get(session__pk=self.future.pk).order, 0) + self.assertFalse(mock_slides_manager_cls.called) + + # adding slides should set order / call Meetecho API + slides = DocumentFactory(type_id="slides") + url = urlreverse("ietf.doc.views_doc.add_sessionpresentation", kwargs=dict(name=slides.name)) + response = self.client.post(url, {"session": self.future.pk, "version": "current"}) + self.assertEqual(response.status_code,302) + self.assertEqual(slides.presentations.get(session__pk=self.future.pk).order, 1) + self.assertEqual(mock_slides_manager_cls.call_args, mock.call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.add.call_args, + mock.call(self.future, slides, order=1), + ) def test_get_related_meeting(self): """Should be able to retrieve related meeting""" diff --git a/ietf/doc/tests_material.py b/ietf/doc/tests_material.py index 2f057573b..065ff09a9 100644 --- a/ietf/doc/tests_material.py +++ b/ietf/doc/tests_material.py @@ -6,19 +6,21 @@ import os import shutil import io +from mock import call, patch from pathlib import Path from pyquery import PyQuery import debug # pyflakes:ignore from django.conf import settings +from django.test import override_settings from django.urls import reverse as urlreverse from django.utils import timezone from ietf.doc.models import Document, State, NewRevisionDocEvent from ietf.group.factories import RoleFactory from ietf.group.models import Group -from ietf.meeting.factories import MeetingFactory, SessionFactory +from ietf.meeting.factories import MeetingFactory, SessionFactory, SessionPresentationFactory from ietf.meeting.models import Meeting, SessionPresentation, SchedulingEvent from ietf.name.models import SessionStatusName from ietf.person.models import Person @@ -135,19 +137,47 @@ class GroupMaterialTests(TestCase): doc = Document.objects.get(name=doc.name) self.assertEqual(doc.get_state_slug(), "deleted") - def test_edit_title(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") + @patch("ietf.doc.views_material.SlidesManager") + def test_edit_title(self, mock_slides_manager_cls): doc = self.create_slides() url = urlreverse('ietf.doc.views_material.edit_material', kwargs=dict(name=doc.name, action="title")) login_testing_unauthorized(self, "secretary", url) + self.assertFalse(mock_slides_manager_cls.called) # post r = self.client.post(url, dict(title="New title")) self.assertEqual(r.status_code, 302) doc = Document.objects.get(name=doc.name) self.assertEqual(doc.title, "New title") + self.assertFalse(mock_slides_manager_cls.return_value.send_update.called) - def test_revise(self): + # assign to a session to see that it now sends updates to Meetecho + session = SessionPresentationFactory(session__group=doc.group, document=doc).session + + # Grab the title on the slides when the API call was made (to be sure it's not before it was updated) + titles_sent = [] + mock_slides_manager_cls.return_value.send_update.side_effect = lambda sess: titles_sent.extend( + list(sess.presentations.values_list("document__title", flat=True)) + ) + + r = self.client.post(url, dict(title="Newer title")) + self.assertEqual(r.status_code, 302) + doc = Document.objects.get(name=doc.name) + self.assertEqual(doc.title, "Newer title") + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.send_update.call_args, + call(session), + ) + self.assertEqual(titles_sent, ["Newer title"]) + + @override_settings(MEETECHO_API_CONFIG="fake settings") + @patch("ietf.doc.views_material.SlidesManager") + def test_revise(self, mock_slides_manager_cls): doc = self.create_slides() session = SessionFactory( @@ -165,11 +195,18 @@ class GroupMaterialTests(TestCase): url = urlreverse('ietf.doc.views_material.edit_material', kwargs=dict(name=doc.name, action="revise")) login_testing_unauthorized(self, "secretary", url) + self.assertFalse(mock_slides_manager_cls.called) content = "some text" test_file = io.StringIO(content) test_file.name = "unnamed.txt" + # Grab the title on the slides when the API call was made (to be sure it's not before it was updated) + titles_sent = [] + mock_slides_manager_cls.return_value.send_update.side_effect = lambda sess: titles_sent.extend( + list(sess.presentations.values_list("document__title", flat=True)) + ) + # post r = self.client.post(url, dict(title="New title", abstract="New abstract", @@ -180,6 +217,14 @@ class GroupMaterialTests(TestCase): self.assertEqual(doc.rev, "02") self.assertEqual(doc.title, "New title") self.assertEqual(doc.get_state_slug(), "active") + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.send_update.call_args, + call(session), + ) + self.assertEqual(titles_sent, ["New title"]) with io.open(os.path.join(doc.get_file_path(), doc.name + "-" + doc.rev + ".txt")) as f: self.assertEqual(f.read(), content) diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 1a260ca97..551ec0cc5 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -42,6 +42,7 @@ import re from pathlib import Path +from django.db.models import Max from django.http import HttpResponse, Http404 from django.shortcuts import render, get_object_or_404, redirect from django.template.loader import render_to_string @@ -74,13 +75,14 @@ from ietf.utils.history import find_history_active_at from ietf.doc.forms import TelechatForm, NotifyForm, ActionHoldersForm, DocAuthorForm, DocAuthorChangeBasisForm from ietf.doc.mails import email_comment, email_remind_action_holders from ietf.mailtrigger.utils import gather_relevant_expansions -from ietf.meeting.models import Session +from ietf.meeting.models import Session, SessionPresentation from ietf.meeting.utils import group_sessions, get_upcoming_manageable_sessions, sort_sessions, add_event_info_to_session_qs from ietf.review.models import ReviewAssignment from ietf.review.utils import can_request_review_of_doc, review_assignments_to_list_for_docs, review_requests_to_list_for_docs from ietf.review.utils import no_review_from_teams_on_doc from ietf.utils import markup_txt, log, markdown from ietf.utils.draft import PlaintextDraft +from ietf.utils.meetecho import MeetechoAPIError, SlidesManager from ietf.utils.response import permission_denied from ietf.utils.text import maybe_split from ietf.utils.timezone import date_today @@ -2070,6 +2072,12 @@ def edit_sessionpresentation(request,name,session_id): new_selection = form.cleaned_data['version'] if initial['version'] != new_selection: doc.presentations.filter(pk=sp.pk).update(rev=None if new_selection=='current' else new_selection) + if doc.type_id == "slides" and hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.send_update(sp.session) + except MeetechoAPIError as err: + log.log(f"Error in SlidesManager.send_update(): {err}") c = DocEvent(type="added_comment", doc=doc, rev=doc.rev, by=request.user.person) c.desc = "Revision for session %s changed to %s" % (sp.session,new_selection) c.save() @@ -2091,6 +2099,12 @@ def remove_sessionpresentation(request,name,session_id): if request.method == 'POST': doc.presentations.filter(pk=sp.pk).delete() + if doc.type_id == "slides" and hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.delete(sp.session, doc) + except MeetechoAPIError as err: + log.log(f"Error in SlidesManager.delete(): {err}") c = DocEvent(type="added_comment", doc=doc, rev=doc.rev, by=request.user.person) c.desc = "Removed from session: %s" % (sp.session) c.save() @@ -2127,7 +2141,25 @@ def add_sessionpresentation(request,name): session_id = session_form.cleaned_data['session'] version = version_form.cleaned_data['version'] rev = None if version=='current' else version - doc.presentations.create(session_id=session_id,rev=rev) + if doc.type_id == "slides": + max_order = SessionPresentation.objects.filter( + document__type='slides', + session__pk=session_id, + ).aggregate(Max('order'))['order__max'] or 0 + order = max_order + 1 + else: + order = 0 + sp = doc.presentations.create( + session_id=session_id, + rev=rev, + order=order, + ) + if doc.type_id == "slides" and hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.add(sp.session, doc, order=sp.order) + except MeetechoAPIError as err: + log.log(f"Error in SlidesManager.add(): {err}") c = DocEvent(type="added_comment", doc=doc, rev=doc.rev, by=request.user.person) c.desc = "%s to session: %s" % ('Added -%s'%rev if rev else 'Added', Session.objects.get(pk=session_id)) c.save() diff --git a/ietf/doc/views_material.py b/ietf/doc/views_material.py index 19bc02cfd..b646ecf2f 100644 --- a/ietf/doc/views_material.py +++ b/ietf/doc/views_material.py @@ -8,6 +8,7 @@ import os import re from django import forms +from django.conf import settings from django.contrib.auth.decorators import login_required from django.http import Http404 from django.shortcuts import render, get_object_or_404, redirect @@ -21,7 +22,9 @@ from ietf.doc.models import NewRevisionDocEvent from ietf.doc.utils import add_state_change_event, check_common_doc_name_rules from ietf.group.models import Group from ietf.group.utils import can_manage_materials +from ietf.utils import log from ietf.utils.decorators import ignore_view_kwargs +from ietf.utils.meetecho import MeetechoAPIError, SlidesManager from ietf.utils.response import permission_denied @login_required @@ -123,6 +126,8 @@ def edit_material(request, name=None, acronym=None, action=None, doc_type=None): if not can_manage_materials(request.user, group): permission_denied(request, "You don't have permission to access this view") + sessions_with_slide_title_updates = set() + if request.method == 'POST': form = UploadMaterialForm(document_type, action, group, doc, request.POST, request.FILES) @@ -175,6 +180,9 @@ def edit_material(request, name=None, acronym=None, action=None, doc_type=None): e.desc += " from %s" % prev_title e.save() events.append(e) + if doc.type_id == "slides": + for sp in doc.presentations.all(): + sessions_with_slide_title_updates.add(sp.session) if prev_abstract != doc.abstract: e = DocEvent(doc=doc, rev=doc.rev, by=request.user.person, type='changed_document') @@ -192,6 +200,16 @@ def edit_material(request, name=None, acronym=None, action=None, doc_type=None): if events: doc.save_with_history(events) + # Call Meetecho API if any session slides titles changed + if sessions_with_slide_title_updates and hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + for session in sessions_with_slide_title_updates: + try: + # SessionPresentations are unique over (session, document) so there will be no duplicates + sm.send_update(session) + except MeetechoAPIError as err: + log.log(f"Error in SlidesManager.send_update(): {err}") + return redirect("ietf.doc.views_doc.document_main", name=doc.name) else: form = UploadMaterialForm(document_type, action, group, doc) diff --git a/ietf/meeting/migrations/0007_attended_origin_attended_time.py b/ietf/meeting/migrations/0007_attended_origin_attended_time.py new file mode 100644 index 000000000..09a8d90e0 --- /dev/null +++ b/ietf/meeting/migrations/0007_attended_origin_attended_time.py @@ -0,0 +1,26 @@ +# Copyright The IETF Trust 2024, All Rights Reserved + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ("meeting", "0006_alter_sessionpresentation_document_and_session"), + ] + + operations = [ + migrations.AddField( + model_name="attended", + name="origin", + field=models.CharField(default="datatracker", max_length=32), + ), + migrations.AddField( + model_name="attended", + name="time", + field=models.DateTimeField( + blank=True, default=django.utils.timezone.now, null=True + ), + ), + ] diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index 781ced787..dd6e2db6c 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -1426,6 +1426,8 @@ class MeetingHost(models.Model): class Attended(models.Model): person = ForeignKey(Person) session = ForeignKey(Session) + time = models.DateTimeField(default=timezone.now, null=True, blank=True) + origin = models.CharField(max_length=32, default='datatracker') class Meta: unique_together = (('person', 'session'),) diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 092f8be89..8d355d97e 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2009-2023, All Rights Reserved +# Copyright The IETF Trust 2009-2024, All Rights Reserved # -*- coding: utf-8 -*- import datetime import io @@ -12,7 +12,7 @@ import requests.exceptions import requests_mock from unittest import skipIf -from mock import patch, PropertyMock +from mock import call, patch, PropertyMock from pyquery import PyQuery from lxml.etree import tostring from io import StringIO, BytesIO @@ -38,16 +38,16 @@ import debug # pyflakes:ignore from ietf.doc.models import Document, NewRevisionDocEvent from ietf.group.models import Group, Role, GroupFeatures from ietf.group.utils import can_manage_group -from ietf.person.models import Person +from ietf.person.models import Person, PersonalApiKey from ietf.meeting.helpers import can_approve_interim_request, can_view_interim_request, preprocess_assignments_for_agenda from ietf.meeting.helpers import send_interim_approval_request, AgendaKeywordTagger from ietf.meeting.helpers import send_interim_meeting_cancellation_notice, send_interim_session_cancellation_notice from ietf.meeting.helpers import send_interim_minutes_reminder, populate_important_dates, update_important_dates from ietf.meeting.models import Session, TimeSlot, Meeting, SchedTimeSessAssignment, Schedule, SessionPresentation, SlideSubmission, SchedulingEvent, Room, Constraint, ConstraintName from ietf.meeting.test_data import make_meeting_test_data, make_interim_meeting, make_interim_test_data -from ietf.meeting.utils import finalize, condition_slide_order +from ietf.meeting.utils import condition_slide_order from ietf.meeting.utils import add_event_info_to_session_qs, participants_for_meeting -from ietf.meeting.utils import create_recording, get_next_sequence +from ietf.meeting.utils import create_recording, get_next_sequence, bluesheet_data from ietf.meeting.views import session_draft_list, parse_agenda_filter_params, sessions_post_save, agenda_extract_schedule from ietf.meeting.views import get_summary_by_area, get_summary_by_type, get_summary_by_purpose from ietf.name.models import SessionStatusName, ImportantDateName, RoleName, ProceedingsMaterialTypeName @@ -517,7 +517,7 @@ class MeetingTests(BaseMeetingTestCase): group = GroupFactory() plain_session = SessionFactory(meeting=meeting, group=group) named_session = SessionFactory(meeting=meeting, group=group, name='I Got a Name') - for doc_type_id in ('agenda', 'minutes', 'bluesheets', 'slides', 'draft'): + for doc_type_id in ('agenda', 'minutes', 'slides', 'draft'): # Set up sessions materials that will have distinct URLs for each session. # This depends on settings.MEETING_DOC_HREFS and may need updating if that changes. SessionPresentationFactory( @@ -3020,7 +3020,9 @@ class EditTimeslotsTests(TestCase): class ReorderSlidesTests(TestCase): - def test_add_slides_to_session(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") # enough to trigger API calls + @patch("ietf.meeting.views.SlidesManager") + def test_add_slides_to_session(self, mock_slides_manager_cls): for type_id in ('ietf','interim'): chair_role = RoleFactory(name_id='chair') session = SessionFactory(group=chair_role.group, meeting__date=date_today() - datetime.timedelta(days=90), meeting__type_id=type_id) @@ -3031,6 +3033,7 @@ class ReorderSlidesTests(TestCase): r = self.client.post(url, {'order':1, 'name':slides.name }) self.assertEqual(r.status_code, 403) self.assertIn('have permission', unicontent(r)) + self.assertFalse(mock_slides_manager_cls.called) self.client.login(username=chair_role.person.user.username, password=chair_role.person.user.username+"+password") @@ -3038,6 +3041,7 @@ class ReorderSlidesTests(TestCase): r = self.client.post(url, {'order':0, 'name':slides.name }) self.assertEqual(r.status_code, 403) self.assertIn('materials cutoff', unicontent(r)) + self.assertFalse(mock_slides_manager_cls.called) session.meeting.date = date_today() session.meeting.save() @@ -3047,49 +3051,62 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('No data',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'garbage':'garbage'}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('order is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'order':0, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('order is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'order':2, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('order is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'order':'garbage', 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('order is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) # Invalid name r = self.client.post(url, {'order':1 }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('name is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'order':1, 'name':'garbage' }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('name is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) # Valid post r = self.client.post(url, {'order':1, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(session.presentations.count(),1) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.add.called) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_args, call(session=session, slides=slides, order=1)) + mock_slides_manager_cls.reset_mock() # Ignore a request to add slides that are already in a session r = self.client.post(url, {'order':1, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(session.presentations.count(),1) + self.assertFalse(mock_slides_manager_cls.called) session2 = SessionFactory(group=session.group, meeting=session.meeting) @@ -3108,6 +3125,11 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.json()['success'],True) self.assertEqual(session2.presentations.get(document=more_slides[0]).order,1) self.assertEqual(list(session2.presentations.order_by('order').values_list('order',flat=True)), list(range(1,5))) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.add.called) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_args, call(session=session2, slides=more_slides[0], order=1)) + mock_slides_manager_cls.reset_mock() # Insert at end r = self.client.post(url, {'order':5, 'name':more_slides[1].name}) @@ -3115,6 +3137,11 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.json()['success'],True) self.assertEqual(session2.presentations.get(document=more_slides[1]).order,5) self.assertEqual(list(session2.presentations.order_by('order').values_list('order',flat=True)), list(range(1,6))) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.add.called) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_args, call(session=session2, slides=more_slides[1], order=5)) + mock_slides_manager_cls.reset_mock() # Insert in middle r = self.client.post(url, {'order':3, 'name':more_slides[2].name}) @@ -3122,8 +3149,15 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.json()['success'],True) self.assertEqual(session2.presentations.get(document=more_slides[2]).order,3) self.assertEqual(list(session2.presentations.order_by('order').values_list('order',flat=True)), list(range(1,7))) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.add.called) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_args, call(session=session2, slides=more_slides[2], order=3)) + mock_slides_manager_cls.reset_mock() - def test_remove_slides_from_session(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") # enough to trigger API calls + @patch("ietf.meeting.views.SlidesManager") + def test_remove_slides_from_session(self, mock_slides_manager_cls): for type_id in ['ietf','interim']: chair_role = RoleFactory(name_id='chair') session = SessionFactory(group=chair_role.group, meeting__date=date_today()-datetime.timedelta(days=90), meeting__type_id=type_id) @@ -3134,6 +3168,7 @@ class ReorderSlidesTests(TestCase): r = self.client.post(url, {'oldIndex':1, 'name':slides.name }) self.assertEqual(r.status_code, 403) self.assertIn('have permission', unicontent(r)) + self.assertFalse(mock_slides_manager_cls.called) self.client.login(username=chair_role.person.user.username, password=chair_role.person.user.username+"+password") @@ -3141,6 +3176,7 @@ class ReorderSlidesTests(TestCase): r = self.client.post(url, {'oldIndex':0, 'name':slides.name }) self.assertEqual(r.status_code, 403) self.assertIn('materials cutoff', unicontent(r)) + self.assertFalse(mock_slides_manager_cls.called) session.meeting.date = date_today() session.meeting.save() @@ -3150,27 +3186,32 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('No data',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'garbage':'garbage'}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'oldIndex':0, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'oldIndex':'garbage', 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) - + self.assertFalse(mock_slides_manager_cls.called) + # No matching thing to delete r = self.client.post(url, {'oldIndex':1, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) session.presentations.create(document=slides, rev=slides.rev, order=1) @@ -3179,11 +3220,13 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('name is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'oldIndex':1, 'name':'garbage' }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('name is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) slides2 = DocumentFactory(type_id='slides') @@ -3192,18 +3235,25 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('SessionPresentation not found',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) session.presentations.create(document=slides2, rev=slides2.rev, order=2) r = self.client.post(url, {'oldIndex':1, 'name':slides2.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('Name does not match index',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) # valid removal r = self.client.post(url, {'oldIndex':1, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(session.presentations.count(),1) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.delete.called) + self.assertEqual(mock_slides_manager_cls.return_value.delete.call_args, call(session=session, slides=slides)) + mock_slides_manager_cls.reset_mock() session2 = SessionFactory(group=session.group, meeting=session.meeting) sp_list = SessionPresentationFactory.create_batch(5, document__type_id='slides', session=session2) @@ -3219,6 +3269,11 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.json()['success'],True) self.assertFalse(session2.presentations.filter(pk=sp_list[0].pk).exists()) self.assertEqual(list(session2.presentations.order_by('order').values_list('order',flat=True)), list(range(1,5))) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.delete.called) + self.assertEqual(mock_slides_manager_cls.return_value.delete.call_args, call(session=session2, slides=sp_list[0].document)) + mock_slides_manager_cls.reset_mock() # delete in middle of list r = self.client.post(url, {'oldIndex':4, 'name':sp_list[4].document.name }) @@ -3226,6 +3281,11 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.json()['success'],True) self.assertFalse(session2.presentations.filter(pk=sp_list[4].pk).exists()) self.assertEqual(list(session2.presentations.order_by('order').values_list('order',flat=True)), list(range(1,4))) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.delete.called) + self.assertEqual(mock_slides_manager_cls.return_value.delete.call_args, call(session=session2, slides=sp_list[4].document)) + mock_slides_manager_cls.reset_mock() # delete at end of list r = self.client.post(url, {'oldIndex':2, 'name':sp_list[2].document.name }) @@ -3233,11 +3293,15 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.json()['success'],True) self.assertFalse(session2.presentations.filter(pk=sp_list[2].pk).exists()) self.assertEqual(list(session2.presentations.order_by('order').values_list('order',flat=True)), list(range(1,3))) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.delete.called) + self.assertEqual(mock_slides_manager_cls.return_value.delete.call_args, call(session=session2, slides=sp_list[2].document)) + mock_slides_manager_cls.reset_mock() - - - - def test_reorder_slides_in_session(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") # enough to trigger API calls + @patch("ietf.meeting.views.SlidesManager") + def test_reorder_slides_in_session(self, mock_slides_manager_cls): def _sppk_at(sppk, positions): return [sppk[p-1] for p in positions] chair_role = RoleFactory(name_id='chair') @@ -3259,6 +3323,7 @@ class ReorderSlidesTests(TestCase): r = self.client.post(url, {'oldIndex':1, 'newIndex':2 }) self.assertEqual(r.status_code, 403) self.assertIn('have permission', unicontent(r)) + self.assertFalse(mock_slides_manager_cls.called) self.client.login(username=chair_role.person.user.username, password=chair_role.person.user.username+"+password") @@ -3266,6 +3331,7 @@ class ReorderSlidesTests(TestCase): r = self.client.post(url, {'oldIndex':1, 'newIndex':2 }) self.assertEqual(r.status_code, 403) self.assertIn('materials cutoff', unicontent(r)) + self.assertFalse(mock_slides_manager_cls.called) session.meeting.date = date_today() session.meeting.save() @@ -3275,57 +3341,95 @@ class ReorderSlidesTests(TestCase): self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'oldIndex':2, 'newIndex':6 }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'oldIndex':2, 'newIndex':2 }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) # Move from beginning r = self.client.post(url, {'oldIndex':1, 'newIndex':3}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[2,3,1,4,5])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() # Move to beginning r = self.client.post(url, {'oldIndex':3, 'newIndex':1}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[1,2,3,4,5])) - + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() + # Move from end r = self.client.post(url, {'oldIndex':5, 'newIndex':3}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[1,2,5,3,4])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() # Move to end r = self.client.post(url, {'oldIndex':3, 'newIndex':5}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[1,2,3,4,5])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() # Move beginning to end r = self.client.post(url, {'oldIndex':1, 'newIndex':5}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[2,3,4,5,1])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() # Move middle to middle r = self.client.post(url, {'oldIndex':3, 'newIndex':4}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[2,3,5,4,1])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() r = self.client.post(url, {'oldIndex':3, 'newIndex':2}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[2,5,3,4,1])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() # Reset for next iteration in the loop session.presentations.update(order=F('pk')) @@ -5997,6 +6101,34 @@ class FinalizeProceedingsTests(TestCase): self.assertEqual(meeting.proceedings_final,True) self.assertEqual(meeting.session_set.filter(group__acronym="mars").first().presentations.filter(document__type="draft").first().rev,'00') + @patch("ietf.meeting.utils.generate_bluesheet") + def test_bluesheet_generation(self, mock): + meeting = MeetingFactory(type_id="ietf", number="107") # number where generate_bluesheets should not be called + SessionFactory.create_batch(5, meeting=meeting) + url = urlreverse("ietf.meeting.views.finalize_proceedings", kwargs={"num": meeting.number}) + self.client.login(username="secretary", password="secretary+password") + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertFalse(mock.called) + r = self.client.post(url,{'finalize': 1}) + self.assertEqual(r.status_code, 302) + self.assertFalse(mock.called) + + meeting = MeetingFactory(type_id="ietf", number="108") # number where generate_bluesheets should be called + SessionFactory.create_batch(5, meeting=meeting) + url = urlreverse("ietf.meeting.views.finalize_proceedings", kwargs={"num": meeting.number}) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertFalse(mock.called) + r = self.client.post(url,{'finalize': 1}) + self.assertEqual(r.status_code, 302) + self.assertTrue(mock.called) + self.assertCountEqual( + [call_args[0][1] for call_args in mock.call_args_list], + [sess for sess in meeting.session_set.all()], + ) + + class MaterialsTests(TestCase): settings_temp_path_overrides = TestCase.settings_temp_path_overrides + [ 'AGENDA_PATH', @@ -6300,7 +6432,9 @@ class MaterialsTests(TestCase): doc = Document.objects.get(pk=doc.pk) self.assertEqual(doc.rev,'02') - def test_upload_slides(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") # enough to trigger API calls + @patch("ietf.meeting.views.SlidesManager") + def test_upload_slides(self, mock_slides_manager_cls): session1 = SessionFactory(meeting__type_id='ietf') session2 = SessionFactory(meeting=session1.meeting,group=session1.group) @@ -6308,6 +6442,7 @@ class MaterialsTests(TestCase): login_testing_unauthorized(self,"secretary",url) r = self.client.get(url) self.assertEqual(r.status_code, 200) + self.assertFalse(mock_slides_manager_cls.called) q = PyQuery(r.content) self.assertIn('Upload', str(q("title"))) self.assertFalse(session1.presentations.filter(document__type_id='slides')) @@ -6320,6 +6455,18 @@ class MaterialsTests(TestCase): sp = session2.presentations.first() self.assertEqual(sp.document.name, 'slides-%s-%s-a-test-slide-file' % (session1.meeting.number,session1.group.acronym ) ) self.assertEqual(sp.order,1) + self.assertEqual(mock_slides_manager_cls.call_count, 1) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_count, 2) + # don't care which order they were called in, just that both sessions were updated + self.assertCountEqual( + mock_slides_manager_cls.return_value.add.call_args_list, + [ + call(session=session1, slides=sp.document, order=1), + call(session=session2, slides=sp.document, order=1), + ], + ) + mock_slides_manager_cls.reset_mock() url = urlreverse('ietf.meeting.views.upload_session_slides',kwargs={'num':session2.meeting.number,'session_id':session2.id}) test_file = BytesIO(b'some other thing still not slidelike') @@ -6332,6 +6479,14 @@ class MaterialsTests(TestCase): self.assertEqual(sp.order,2) self.assertEqual(sp.rev,'00') self.assertEqual(sp.document.rev,'00') + self.assertEqual(mock_slides_manager_cls.call_count, 1) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.add.call_args, + call(session=session2, slides=sp.document, order=2), + ) + mock_slides_manager_cls.reset_mock() url = urlreverse('ietf.meeting.views.upload_session_slides',kwargs={'num':session2.meeting.number,'session_id':session2.id,'name':session2.presentations.get(order=2).document.name}) r = self.client.get(url) @@ -6344,10 +6499,17 @@ class MaterialsTests(TestCase): self.assertEqual(r.status_code, 302) self.assertEqual(session1.presentations.count(),1) self.assertEqual(session2.presentations.count(),2) - sp = session2.presentations.get(order=2) - self.assertEqual(sp.rev,'01') - self.assertEqual(sp.document.rev,'01') - + replacement_sp = session2.presentations.get(order=2) + self.assertEqual(replacement_sp.rev,'01') + self.assertEqual(replacement_sp.document.rev,'01') + self.assertEqual(mock_slides_manager_cls.call_count, 1) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.revise.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.revise.call_args, + call(session=session2, slides=sp.document), + ) + def test_upload_slide_title_bad_unicode(self): session1 = SessionFactory(meeting__type_id='ietf') url = urlreverse('ietf.meeting.views.upload_session_slides',kwargs={'num':session1.meeting.number,'session_id':session1.id}) @@ -6365,29 +6527,61 @@ class MaterialsTests(TestCase): self.assertTrue(q('form .is-invalid')) self.assertIn("Unicode BMP", q('form .is-invalid div').text()) - def test_remove_sessionpresentation(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") # enough to trigger API calls + @patch("ietf.meeting.views.SlidesManager") + def test_remove_sessionpresentation(self, mock_slides_manager_cls): session = SessionFactory(meeting__type_id='ietf') + agenda = DocumentFactory(type_id='agenda') doc = DocumentFactory(type_id='slides') + session.presentations.create(document=agenda) session.presentations.create(document=doc) url = urlreverse('ietf.meeting.views.remove_sessionpresentation',kwargs={'num':session.meeting.number,'session_id':session.id,'name':'no-such-doc'}) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) url = urlreverse('ietf.meeting.views.remove_sessionpresentation',kwargs={'num':session.meeting.number,'session_id':0,'name':doc.name}) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) url = urlreverse('ietf.meeting.views.remove_sessionpresentation',kwargs={'num':session.meeting.number,'session_id':session.id,'name':doc.name}) login_testing_unauthorized(self,"secretary",url) response = self.client.get(url) self.assertEqual(response.status_code, 200) + self.assertFalse(mock_slides_manager_cls.called) - self.assertEqual(1,session.presentations.count()) + # Removing slides should remove the materials and call MeetechoAPI + self.assertEqual(2, session.presentations.count()) response = self.client.post(url,{'remove_session':''}) self.assertEqual(response.status_code, 302) - self.assertEqual(0,session.presentations.count()) - self.assertEqual(2,doc.docevent_set.count()) + self.assertEqual(1, session.presentations.count()) + self.assertEqual(2, doc.docevent_set.count()) + self.assertEqual(mock_slides_manager_cls.call_count, 1) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.delete.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.delete.call_args, + call(session=session, slides=doc), + ) + mock_slides_manager_cls.reset_mock() + + # Removing non-slides should only remove the materials + url = urlreverse( + "ietf.meeting.views.remove_sessionpresentation", + kwargs={ + "num": session.meeting.number, + "session_id": session.id, + "name": agenda.name, + }, + ) + response = self.client.post(url, {"remove_session" : ""}) + self.assertEqual(response.status_code, 302) + self.assertEqual(0, session.presentations.count()) + self.assertEqual(2, agenda.docevent_set.count()) + self.assertFalse(mock_slides_manager_cls.called) + def test_propose_session_slides(self): for type_id in ['ietf','interim']: @@ -7811,7 +8005,7 @@ class ProceedingsTests(BaseMeetingTestCase): def test_named_session(self): """Session with a name should appear separately in the proceedings""" - meeting = MeetingFactory(type_id='ietf', number='100') + meeting = MeetingFactory(type_id='ietf', number='100', proceedings_final=True) group = GroupFactory() plain_session = SessionFactory(meeting=meeting, group=group) named_session = SessionFactory(meeting=meeting, group=group, name='I Got a Name') @@ -7932,7 +8126,6 @@ class ProceedingsTests(BaseMeetingTestCase): - prefer onsite checkedin=True to remote attended when same person has both """ - make_meeting_test_data() meeting = MeetingFactory(type_id='ietf', date=datetime.date(2023, 11, 4), number="118") person_a = PersonFactory(name='Person A') person_b = PersonFactory(name='Person B') @@ -7957,9 +8150,14 @@ class ProceedingsTests(BaseMeetingTestCase): '''Test proceedings IETF Overview page. Note: old meetings aren't supported so need to add a new meeting then test. ''' - make_meeting_test_data() - meeting = MeetingFactory(type_id='ietf', date=datetime.date(2016,7,14), number="97") - finalize(meeting) + meeting = make_meeting_test_data(meeting=MeetingFactory(type_id='ietf', date=datetime.date(2016,7,14), number="97")) + + # finalize meeting + url = urlreverse('ietf.meeting.views.finalize_proceedings',kwargs={'num':meeting.number}) + login_testing_unauthorized(self,"secretary",url) + r = self.client.post(url,{'finalize':1}) + self.assertEqual(r.status_code, 302) + url = urlreverse('ietf.meeting.views.proceedings_overview',kwargs={'num':97}) response = self.client.get(url) self.assertContains(response, 'The Internet Engineering Task Force') @@ -8362,3 +8560,127 @@ class ProceedingsTests(BaseMeetingTestCase): self.assertTrue(person_b.pk not in checked_in) self.assertTrue(person_c.pk in attended) self.assertTrue(person_d.pk not in attended) + + def test_session_attendance(self): + meeting = MeetingFactory(type_id='ietf', date=datetime.date(2023, 11, 4), number='118') + make_meeting_test_data(meeting=meeting) + session = Session.objects.filter(meeting=meeting, group__acronym='mars').first() + regs = MeetingRegistrationFactory.create_batch(3, meeting=meeting) + persons = [reg.person for reg in regs] + self.assertEqual(session.attended_set.count(), 0) + + # If there are no attendees, the link isn't offered, and getting + # the page directly returns an empty list. + session_url = urlreverse('ietf.meeting.views.session_details', kwargs={'num':meeting.number, 'acronym':session.group.acronym}) + attendance_url = urlreverse('ietf.meeting.views.session_attendance', kwargs={'num':meeting.number, 'session_id':session.id}) + r = self.client.get(session_url) + self.assertNotContains(r, attendance_url) + r = self.client.get(attendance_url) + self.assertEqual(r.status_code, 200) + self.assertContains(r, '0 attendees') + + # Add some attendees + add_attendees_url = urlreverse('ietf.meeting.views.api_add_session_attendees') + recmanrole = RoleFactory(group__type_id='ietf', name_id='recman', person__user__last_login=timezone.now()) + recman = recmanrole.person + apikey = PersonalApiKey.objects.create(endpoint=add_attendees_url, person=recman) + attendees = [person.user.pk for person in persons] + self.client.login(username='recman', password='recman+password') + r = self.client.post(add_attendees_url, {'apikey':apikey.hash(), 'attended':f'{{"session_id":{session.pk},"attendees":{attendees}}}'}) + self.assertEqual(r.status_code, 200) + self.assertEqual(session.attended_set.count(), 3) + + # Before a meeting is finalized, session_attendance renders a live + # view of the Attended records for the session. + r = self.client.get(session_url) + self.assertContains(r, attendance_url) + r = self.client.get(attendance_url) + self.assertEqual(r.status_code, 200) + self.assertContains(r, '3 attendees') + for person in persons: + self.assertContains(r, person.name) + + # Test for the "I was there" button. + def _test_button(person, expected): + username = person.user.username + self.client.login(username=username, password=f'{username}+password') + r = self.client.get(attendance_url) + self.assertEqual(b"I was there" in r.content, expected) + # recman isn't registered for the meeting + _test_button(recman, False) + # person0 is already on the bluesheet + _test_button(persons[0], False) + # person3 attests he was there + persons.append(MeetingRegistrationFactory(meeting=meeting).person) + # button isn't shown if we're outside the corrections windows + meeting.importantdate_set.create(name_id='revsub',date=date_today() - datetime.timedelta(days=20)) + _test_button(persons[3], False) + # attempt to POST anyway is ignored + r = self.client.post(attendance_url) + self.assertEqual(r.status_code, 200) + self.assertNotContains(r, persons[3].name) + self.assertEqual(session.attended_set.count(), 3) + # button is shown, and POST is accepted + meeting.importantdate_set.update(name_id='revsub',date=date_today() + datetime.timedelta(days=20)) + _test_button(persons[3], True) + r = self.client.post(attendance_url) + self.assertEqual(r.status_code, 200) + self.assertContains(r, persons[3].name) + self.assertEqual(session.attended_set.count(), 4) + + # When the meeting is finalized, a bluesheet file is generated, + # and session_attendance redirects to the file. + self.client.login(username='secretary',password='secretary+password') + finalize_url = urlreverse('ietf.meeting.views.finalize_proceedings', kwargs={'num':meeting.number}) + r = self.client.post(finalize_url, {'finalize':1}) + self.assertRedirects(r, urlreverse('ietf.meeting.views.proceedings', kwargs={'num':meeting.number})) + doc = session.presentations.filter(document__type_id='bluesheets').first().document + self.assertEqual(doc.rev,'00') + text = doc.text() + self.assertIn('4 attendees', text) + for person in persons: + self.assertIn(person.name, text) + r = self.client.get(session_url) + self.assertContains(r, doc.get_href()) + self.assertNotContains(r, attendance_url) + r = self.client.get(attendance_url) + self.assertEqual(r.status_code,302) + self.assertEqual(r['Location'],doc.get_href()) + + # An interim meeting is considered finalized immediately. + meeting = make_interim_meeting(group=GroupFactory(acronym='mars'), date=date_today()) + session = Session.objects.filter(meeting=meeting, group__acronym='mars').first() + attendance_url = urlreverse('ietf.meeting.views.session_attendance', kwargs={'num':meeting.number, 'session_id':session.id}) + self.assertEqual(session.attended_set.count(), 0) + self.client.login(username='recman', password='recman+password') + attendees = [person.user.pk for person in persons] + r = self.client.post(add_attendees_url, {'apikey':apikey.hash(), 'attended':f'{{"session_id":{session.pk},"attendees":{attendees}}}'}) + self.assertEqual(r.status_code, 200) + self.assertEqual(session.attended_set.count(), 4) + doc = session.presentations.filter(document__type_id='bluesheets').first().document + self.assertEqual(doc.rev,'00') + session_url = urlreverse('ietf.meeting.views.session_details', kwargs={'num':meeting.number, 'acronym':session.group.acronym}) + r = self.client.get(session_url) + self.assertContains(r, doc.get_href()) + self.assertNotContains(r, attendance_url) + r = self.client.get(attendance_url) + self.assertEqual(r.status_code,302) + self.assertEqual(r['Location'],doc.get_href()) + + def test_bluesheet_data(self): + session = SessionFactory(meeting__type_id="ietf") + attended_with_affil = MeetingRegistrationFactory(meeting=session.meeting, affiliation="Somewhere") + AttendedFactory(session=session, person=attended_with_affil.person, time="2023-03-13T01:24:00Z") # joined 2nd + attended_no_affil = MeetingRegistrationFactory(meeting=session.meeting) + AttendedFactory(session=session, person=attended_no_affil.person, time="2023-03-13T01:23:00Z") # joined 1st + MeetingRegistrationFactory(meeting=session.meeting) # did not attend + + data = bluesheet_data(session) + self.assertEqual( + data, + [ + {"name": attended_no_affil.person.plain_name(), "affiliation": ""}, + {"name": attended_with_affil.person.plain_name(), "affiliation": "Somewhere"}, + ] + ) + diff --git a/ietf/meeting/urls.py b/ietf/meeting/urls.py index 1c6e49213..26d3d93b2 100644 --- a/ietf/meeting/urls.py +++ b/ietf/meeting/urls.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2007-2020, All Rights Reserved +# Copyright The IETF Trust 2007-2024, All Rights Reserved from django.conf import settings from django.urls import include @@ -16,6 +16,7 @@ class AgendaRedirectView(RedirectView): safe_for_all_meeting_types = [ url(r'^session/(?P[-a-z0-9]+)/?$', views.session_details), url(r'^session/(?P\d+)/drafts$', views.add_session_drafts), + url(r'^session/(?P\d+)/attendance$', views.session_attendance), url(r'^session/(?P\d+)/bluesheets$', views.upload_session_bluesheets), url(r'^session/(?P\d+)/minutes$', views.upload_session_minutes), url(r'^session/(?P\d+)/narrativeminutes$', views.upload_session_narrativeminutes), diff --git a/ietf/meeting/utils.py b/ietf/meeting/utils.py index 6469dbfbb..8d44cb7c1 100644 --- a/ietf/meeting/utils.py +++ b/ietf/meeting/utils.py @@ -5,6 +5,7 @@ import itertools import os import pytz import subprocess +import tempfile from collections import defaultdict from pathlib import Path @@ -12,6 +13,7 @@ from pathlib import Path from django.conf import settings from django.contrib import messages from django.db.models import Q +from django.template.loader import render_to_string from django.utils import timezone from django.utils.encoding import smart_str @@ -26,6 +28,7 @@ from ietf.group.models import Group from ietf.group.utils import can_manage_materials from ietf.name.models import SessionStatusName, ConstraintName, DocTypeName from ietf.person.models import Person +from ietf.stats.models import MeetingRegistration from ietf.utils.html import sanitize_document from ietf.utils.log import log from ietf.utils.timezone import date_today @@ -144,7 +147,84 @@ def create_proceedings_templates(meeting): meeting.overview = template meeting.save() -def finalize(meeting): + +def bluesheet_data(session): + def affiliation(meeting, person): + # from OidcExtraScopeClaims.scope_registration() + email_list = person.email_set.values_list("address") + q = Q(person=person, meeting=meeting) | Q(email__in=email_list, meeting=meeting) + reg = MeetingRegistration.objects.filter(q).exclude(affiliation="").first() + return reg.affiliation if reg else "" + + attendance = Attended.objects.filter(session=session).order_by("time") + meeting = session.meeting + return [ + { + "name": attended.person.plain_name(), + "affiliation": affiliation(meeting, attended.person), + } + for attended in attendance + ] + + +def save_bluesheet(request, session, file, encoding='utf-8'): + bluesheet_sp = session.presentations.filter(document__type='bluesheets').first() + _, ext = os.path.splitext(file.name) + + if bluesheet_sp: + doc = bluesheet_sp.document + doc.rev = '%02d' % (int(doc.rev)+1) + bluesheet_sp.rev = doc.rev + bluesheet_sp.save() + else: + ota = session.official_timeslotassignment() + sess_time = ota and ota.timeslot.time + + if session.meeting.type_id=='ietf': + name = 'bluesheets-%s-%s-%s' % (session.meeting.number, + session.group.acronym, + sess_time.strftime("%Y%m%d%H%M")) + title = 'Bluesheets IETF%s: %s : %s' % (session.meeting.number, + session.group.acronym, + sess_time.strftime("%a %H:%M")) + else: + name = 'bluesheets-%s-%s' % (session.meeting.number, sess_time.strftime("%Y%m%d%H%M")) + title = 'Bluesheets %s: %s' % (session.meeting.number, sess_time.strftime("%a %H:%M")) + doc = Document.objects.create( + name = name, + type_id = 'bluesheets', + title = title, + group = session.group, + rev = '00', + ) + doc.states.add(State.objects.get(type_id='bluesheets',slug='active')) + session.presentations.create(document=doc,rev='00') + filename = '%s-%s%s'% ( doc.name, doc.rev, ext) + doc.uploaded_filename = filename + e = NewRevisionDocEvent.objects.create(doc=doc, rev=doc.rev, by=request.user.person, type='new_revision', desc='New revision available: %s'%doc.rev) + save_error = handle_upload_file(file, filename, session.meeting, 'bluesheets', request=request, encoding=encoding) + if not save_error: + doc.save_with_history([e]) + return save_error + + +def generate_bluesheet(request, session): + data = bluesheet_data(session) + if not data: + return + text = render_to_string('meeting/bluesheet.txt', { + 'session': session, + 'data': data, + }) + fd, name = tempfile.mkstemp(suffix=".txt", text=True) + os.close(fd) + with open(name, "w") as file: + file.write(text) + with open(name, "br") as file: + return save_bluesheet(request, session, file) + + +def finalize(request, meeting): end_date = meeting.end_date() end_time = meeting.tz().localize( datetime.datetime.combine( @@ -160,6 +240,12 @@ def finalize(meeting): else: sp.rev = '00' sp.save() + + # Don't try to generate a bluesheet if it's before we had Attended records. + if int(meeting.number) >= 108: + save_error = generate_bluesheet(request, session) + if save_error: + messages.error(request, save_error) create_proceedings_templates(meeting) meeting.proceedings_final = True diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 6de8360b6..5bb1a922b 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -19,6 +19,7 @@ import shutil from calendar import timegm from collections import OrderedDict, Counter, deque, defaultdict, namedtuple from functools import partialmethod +import jsonschema from urllib.parse import parse_qs, unquote, urlencode, urlsplit, urlunsplit from tempfile import mkstemp from wsgiref.handlers import format_date_time @@ -54,7 +55,7 @@ from ietf.group.utils import can_manage_session_materials, can_manage_some_group from ietf.person.models import Person, User from ietf.ietfauth.utils import role_required, has_role, user_is_person from ietf.mailtrigger.utils import gather_address_lists -from ietf.meeting.models import Meeting, Session, Schedule, FloorPlan, SessionPresentation, TimeSlot, SlideSubmission +from ietf.meeting.models import Meeting, Session, Schedule, FloorPlan, SessionPresentation, TimeSlot, SlideSubmission, Attended from ietf.meeting.models import SessionStatusName, SchedulingEvent, SchedTimeSessAssignment, Room, TimeSlotTypeName from ietf.meeting.forms import ( CustomDurationField, SwapDaysForm, SwapTimeslotsForm, ImportMinutesForm, TimeSlotCreateForm, TimeSlotEditForm, SessionCancelForm, SessionEditForm ) @@ -84,14 +85,15 @@ from ietf.meeting.utils import swap_meeting_schedule_timeslot_assignments, bulk_ from ietf.meeting.utils import preprocess_meeting_important_dates from ietf.meeting.utils import new_doc_for_session, write_doc_for_session from ietf.meeting.utils import get_activity_stats, post_process, create_recording -from ietf.meeting.utils import participants_for_meeting +from ietf.meeting.utils import participants_for_meeting, generate_bluesheet, bluesheet_data, save_bluesheet from ietf.message.utils import infer_message from ietf.name.models import SlideSubmissionStatusName, ProceedingsMaterialTypeName, SessionPurposeName from ietf.stats.models import MeetingRegistration from ietf.utils import markdown from ietf.utils.decorators import require_api_key from ietf.utils.hedgedoc import Note, NoteError -from ietf.utils.log import assertion +from ietf.utils.meetecho import MeetechoAPIError, SlidesManager +from ietf.utils.log import assertion, log from ietf.utils.mail import send_mail_message, send_mail_text from ietf.utils.mime import get_mime_type from ietf.utils.pipe import pipe @@ -1694,45 +1696,58 @@ def api_get_agenda_data (request, num=None): "floors": list(map(agenda_extract_floorplan, floors)) }) -def api_get_session_materials (request, session_id=None): - session = get_object_or_404(Session,pk=session_id) + +def api_get_session_materials(request, session_id=None): + session = get_object_or_404(Session, pk=session_id) minutes = session.minutes() slides_actions = [] if can_manage_session_materials(request.user, session.group, session): - slides_actions.append({ - 'label': 'Upload slides', - 'url': reverse( - 'ietf.meeting.views.upload_session_slides', - kwargs={'num': session.meeting.number, 'session_id': session.pk}, - ), - }) + slides_actions.append( + { + "label": "Upload slides", + "url": reverse( + "ietf.meeting.views.upload_session_slides", + kwargs={"num": session.meeting.number, "session_id": session.pk}, + ), + } + ) elif not session.is_material_submission_cutoff(): - slides_actions.append({ - 'label': 'Propose slides', - 'url': reverse( - 'ietf.meeting.views.propose_session_slides', - kwargs={'num': session.meeting.number, 'session_id': session.pk}, - ), - }) + slides_actions.append( + { + "label": "Propose slides", + "url": reverse( + "ietf.meeting.views.propose_session_slides", + kwargs={"num": session.meeting.number, "session_id": session.pk}, + ), + } + ) else: pass # no action available if it's past cutoff - - agenda = session.agenda() + + agenda = session.agenda() agenda_url = agenda.get_href() if agenda is not None else None - return JsonResponse({ - "url": agenda_url, - "slides": { - "decks": list(map(agenda_extract_slide, session.slides())), - "actions": slides_actions, - }, - "minutes": { - "id": minutes.id, - "title": minutes.title, - "url": minutes.get_href(), - "ext": minutes.file_extension() - } if minutes is not None else None - }) + return JsonResponse( + { + "url": agenda_url, + "slides": { + "decks": [ + agenda_extract_slide(slide) | {"order": order} # add "order" field + for order, slide in enumerate(session.slides()) + ], + "actions": slides_actions, + }, + "minutes": { + "id": minutes.id, + "title": minutes.title, + "url": minutes.get_href(), + "ext": minutes.file_extension(), + } + if minutes is not None + else None, + } + ) + def agenda_extract_schedule (item): return { @@ -1755,9 +1770,9 @@ def agenda_extract_schedule (item): "filterKeywords": item.filter_keywords, "groupAcronym": item.session.group_at_the_time().acronym, "groupName": item.session.group_at_the_time().name, - "groupParent": { + "groupParent": ({ "acronym": item.session.group_parent_at_the_time().acronym - } if item.session.group_parent_at_the_time() else {}, + } if item.session.group_parent_at_the_time() else {}), "note": item.session.agenda_note, "remoteInstructions": item.session.remote_instructions, "flags": { @@ -1790,7 +1805,8 @@ def agenda_extract_schedule (item): # } } -def agenda_extract_floorplan (item): + +def agenda_extract_floorplan(item): try: item.image.width except FileNotFoundError: @@ -1803,10 +1819,11 @@ def agenda_extract_floorplan (item): "short": item.short, "width": item.image.width, "height": item.image.height, - "rooms": list(map(agenda_extract_room, item.room_set.all())) + "rooms": list(map(agenda_extract_room, item.room_set.all())), } -def agenda_extract_room (item): + +def agenda_extract_room(item): return { "id": item.id, "name": item.name, @@ -1818,7 +1835,8 @@ def agenda_extract_room (item): "bottom": item.bottom() } -def agenda_extract_recording (item): + +def agenda_extract_recording(item): return { "id": item.id, "name": item.name, @@ -1826,14 +1844,17 @@ def agenda_extract_recording (item): "url": item.external_url } -def agenda_extract_slide (item): + +def agenda_extract_slide(item): return { "id": item.id, "title": item.title, + "rev": item.rev, "url": item.get_versionless_href(), - "ext": item.file_extension() + "ext": item.file_extension(), } + def agenda_csv(schedule, filtered_assignments, utc=False): encoding = 'utf-8' response = HttpResponse(content_type=f"text/csv; charset={encoding}") @@ -2428,8 +2449,19 @@ def session_details(request, num, acronym): session.cancelled = session.current_status in Session.CANCELED_STATUSES session.status = status_names.get(session.current_status, session.current_status) - session.filtered_artifacts = list(session.presentations.filter(document__type__slug__in=['agenda','minutes','narrativeminutes', 'bluesheets'])) - session.filtered_artifacts.sort(key=lambda d:['agenda','minutes', 'narrativeminutes', 'bluesheets'].index(d.document.type.slug)) + if session.meeting.type_id == 'ietf' and not session.meeting.proceedings_final: + artifact_types = ['agenda','minutes','narrativeminutes'] + if Attended.objects.filter(session=session).exists(): + session.type_counter.update(['bluesheets']) + ota = session.official_timeslotassignment() + sess_time = ota and ota.timeslot.time + session.bluesheet_title = 'Attendance IETF%s: %s : %s' % (session.meeting.number, + session.group.acronym, + sess_time.strftime("%a %H:%M")) + else: + artifact_types = ['agenda','minutes','narrativeminutes','bluesheets'] + session.filtered_artifacts = list(session.presentations.filter(document__type__slug__in=artifact_types)) + session.filtered_artifacts.sort(key=lambda d:artifact_types.index(d.document.type.slug)) session.filtered_slides = session.presentations.filter(document__type__slug='slides').order_by('order') session.filtered_drafts = session.presentations.filter(document__type__slug='draft') session.filtered_chatlog_and_polls = session.presentations.filter(document__type__slug__in=('chatlog', 'polls')).order_by('document__type__slug') @@ -2518,6 +2550,66 @@ def add_session_drafts(request, session_id, num): }) +def session_attendance(request, session_id, num): + """Session attendance view + + GET - retrieve the current session attendance or redirect to the published bluesheet if finalized + + POST - self-attest attendance for logged-in user; falls through to GET for AnonymousUser or invalid request + """ + # num is redundant, but we're dragging it along as an artifact of where we are in the current URL structure + session = get_object_or_404(Session, pk=session_id) + if session.meeting.type_id != "ietf" or session.meeting.proceedings_final: + bluesheets = session.presentations.filter( + document__type_id="bluesheets" + ) + if bluesheets: + bluesheet = bluesheets[0].document + return redirect(bluesheet.get_href(session.meeting)) + else: + raise Http404("Bluesheets not found") + + cor_cut_off_date = session.meeting.get_submission_correction_date() + today_utc = date_today(datetime.timezone.utc) + was_there = False + can_add = False + if request.user.is_authenticated: + # use getattr() instead of request.user.person because it's a reverse OneToOne field + person = getattr(request.user, "person", None) + # Consider allowing self-declared attendance if we have a person and at least one Attended instance exists. + # The latter condition will be satisfied when Meetecho pushes their attendee records - assuming that at least + # one person will have accessed the meeting tool. This prevents people from self-declaring before they are + # marked as attending if they did log in to the meeting tool (except for a tiny window while records are + # being processed). + if person is not None and Attended.objects.filter(session=session).exists(): + was_there = Attended.objects.filter(session=session, person=person).exists() + can_add = ( + today_utc <= cor_cut_off_date + and MeetingRegistration.objects.filter( + meeting=session.meeting, person=person + ).exists() + and not was_there + ) + if can_add and request.method == "POST": + session.attended_set.get_or_create( + person=person, defaults={"origin": "self declared"} + ) + can_add = False + was_there = True + + data = bluesheet_data(session) + return render( + request, + "meeting/attendance.html", + { + "session": session, + "data": data, + "can_add": can_add, + "was_there": was_there, + }, + ) + + def upload_session_bluesheets(request, session_id, num): # num is redundant, but we're dragging it along an artifact of where we are in the current URL structure session = get_object_or_404(Session,pk=session_id) @@ -2565,47 +2657,6 @@ def upload_session_bluesheets(request, session_id, num): }) -def save_bluesheet(request, session, file, encoding='utf-8'): - bluesheet_sp = session.presentations.filter(document__type='bluesheets').first() - _, ext = os.path.splitext(file.name) - - if bluesheet_sp: - doc = bluesheet_sp.document - doc.rev = '%02d' % (int(doc.rev)+1) - bluesheet_sp.rev = doc.rev - bluesheet_sp.save() - else: - ota = session.official_timeslotassignment() - sess_time = ota and ota.timeslot.time - - if session.meeting.type_id=='ietf': - name = 'bluesheets-%s-%s-%s' % (session.meeting.number, - session.group.acronym, - sess_time.strftime("%Y%m%d%H%M")) - title = 'Bluesheets IETF%s: %s : %s' % (session.meeting.number, - session.group.acronym, - sess_time.strftime("%a %H:%M")) - else: - name = 'bluesheets-%s-%s' % (session.meeting.number, sess_time.strftime("%Y%m%d%H%M")) - title = 'Bluesheets %s: %s' % (session.meeting.number, sess_time.strftime("%a %H:%M")) - doc = Document.objects.create( - name = name, - type_id = 'bluesheets', - title = title, - group = session.group, - rev = '00', - ) - doc.states.add(State.objects.get(type_id='bluesheets',slug='active')) - session.presentations.create(document=doc,rev='00') - filename = '%s-%s%s'% ( doc.name, doc.rev, ext) - doc.uploaded_filename = filename - e = NewRevisionDocEvent.objects.create(doc=doc, rev=doc.rev, by=request.user.person, type='new_revision', desc='New revision available: %s'%doc.rev) - save_error = handle_upload_file(file, filename, session.meeting, 'bluesheets', request=request, encoding=encoding) - if not save_error: - doc.save_with_history([e]) - return save_error - - def upload_session_minutes(request, session_id, num): # num is redundant, but we're dragging it along an artifact of where we are in the current URL structure session = get_object_or_404(Session,pk=session_id) @@ -2869,103 +2920,175 @@ def upload_session_agenda(request, session_id, num): def upload_session_slides(request, session_id, num, name=None): + """Upload new or replacement slides for a session + + If name is None or "", expects a new set of slides. Otherwise, replaces the named slides with a new rev. + """ # num is redundant, but we're dragging it along an artifact of where we are in the current URL structure - session = get_object_or_404(Session,pk=session_id) + session = get_object_or_404(Session, pk=session_id) if not session.can_manage_materials(request.user): - permission_denied(request, "You don't have permission to upload slides for this session.") - if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"): - permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.") + permission_denied( + request, "You don't have permission to upload slides for this session." + ) + if session.is_material_submission_cutoff() and not has_role( + request.user, "Secretariat" + ): + permission_denied( + request, + "The materials cutoff for this session has passed. Contact the secretariat for further action.", + ) session_number = None - sessions = get_sessions(session.meeting.number,session.group.acronym) - show_apply_to_all_checkbox = len(sessions) > 1 if session.type_id == 'regular' else False + sessions = get_sessions(session.meeting.number, session.group.acronym) + show_apply_to_all_checkbox = ( + len(sessions) > 1 if session.type_id == "regular" else False + ) if len(sessions) > 1: - session_number = 1 + sessions.index(session) + session_number = 1 + sessions.index(session) - slides = None - slides_sp = None + doc = None if name: - slides = Document.objects.filter(name=name).first() - if not (slides and slides.type_id=='slides'): - raise Http404 - slides_sp = session.presentations.filter(document=slides).first() - - if request.method == 'POST': - form = UploadSlidesForm(session, show_apply_to_all_checkbox,request.POST,request.FILES) + doc = get_object_or_404( + session.presentations, document__name=name, document__type_id="slides" + ).document + + if request.method == "POST": + form = UploadSlidesForm( + session, show_apply_to_all_checkbox, request.POST, request.FILES + ) if form.is_valid(): - file = request.FILES['file'] + file = request.FILES["file"] _, ext = os.path.splitext(file.name) - apply_to_all = session.type_id == 'regular' + apply_to_all = session.type_id == "regular" if show_apply_to_all_checkbox: - apply_to_all = form.cleaned_data['apply_to_all'] - if slides_sp: - doc = slides_sp.document - doc.rev = '%02d' % (int(doc.rev)+1) - doc.title = form.cleaned_data['title'] - slides_sp.rev = doc.rev - slides_sp.save() + apply_to_all = form.cleaned_data["apply_to_all"] + + # Handle creation / update of the Document (but do not save yet) + if doc is not None: + # This is a revision - bump the version and update the title. + doc.rev = "%02d" % (int(doc.rev) + 1) + doc.title = form.cleaned_data["title"] else: - title = form.cleaned_data['title'] - if session.meeting.type_id=='ietf': - name = 'slides-%s-%s' % (session.meeting.number, - session.group.acronym) + # This is a new slide deck - create a new doc unless one exists with that name + title = form.cleaned_data["title"] + if session.meeting.type_id == "ietf": + name = "slides-%s-%s" % ( + session.meeting.number, + session.group.acronym, + ) if not apply_to_all: - name += '-%s' % (session.docname_token(),) + name += "-%s" % (session.docname_token(),) else: - name = 'slides-%s-%s' % (session.meeting.number, session.docname_token()) - name = name + '-' + slugify(title).replace('_', '-')[:128] + name = "slides-%s-%s" % ( + session.meeting.number, + session.docname_token(), + ) + name = name + "-" + slugify(title).replace("_", "-")[:128] if Document.objects.filter(name=name).exists(): - doc = Document.objects.get(name=name) - doc.rev = '%02d' % (int(doc.rev)+1) - doc.title = form.cleaned_data['title'] + doc = Document.objects.get(name=name) + doc.rev = "%02d" % (int(doc.rev) + 1) + doc.title = form.cleaned_data["title"] else: doc = Document.objects.create( - name = name, - type_id = 'slides', - title = title, - group = session.group, - rev = '00', - ) - doc.states.add(State.objects.get(type_id='slides',slug='active')) - doc.states.add(State.objects.get(type_id='reuse_policy',slug='single')) - if session.presentations.filter(document=doc).exists(): - sp = session.presentations.get(document=doc) - sp.rev = doc.rev - sp.save() - else: - max_order = session.presentations.filter(document__type='slides').aggregate(Max('order'))['order__max'] or 0 - session.presentations.create(document=doc,rev=doc.rev,order=max_order+1) - if apply_to_all: - for other_session in sessions: - if other_session != session and not other_session.presentations.filter(document=doc).exists(): - max_order = other_session.presentations.filter(document__type='slides').aggregate(Max('order'))['order__max'] or 0 - other_session.presentations.create(document=doc,rev=doc.rev,order=max_order+1) - filename = '%s-%s%s'% ( doc.name, doc.rev, ext) + name=name, + type_id="slides", + title=title, + group=session.group, + rev="00", + ) + doc.states.add(State.objects.get(type_id="slides", slug="active")) + doc.states.add(State.objects.get(type_id="reuse_policy", slug="single")) + + # Now handle creation / update of the SessionPresentation(s) + sessions_to_apply = sessions if apply_to_all else [session] + added_presentations = [] + revised_presentations = [] + for sess in sessions_to_apply: + sp = sess.presentations.filter(document=doc).first() + if sp is not None: + sp.rev = doc.rev + sp.save() + revised_presentations.append(sp) + else: + max_order = ( + sess.presentations.filter(document__type="slides").aggregate( + Max("order") + )["order__max"] + or 0 + ) + sp = sess.presentations.create( + document=doc, rev=doc.rev, order=max_order + 1 + ) + added_presentations.append(sp) + + # Now handle the uploaded file + filename = "%s-%s%s" % (doc.name, doc.rev, ext) doc.uploaded_filename = filename - e = NewRevisionDocEvent.objects.create(doc=doc,by=request.user.person,type='new_revision',desc='New revision available: %s'%doc.rev,rev=doc.rev) + e = NewRevisionDocEvent.objects.create( + doc=doc, + by=request.user.person, + type="new_revision", + desc="New revision available: %s" % doc.rev, + rev=doc.rev, + ) # The way this function builds the filename it will never trigger the file delete in handle_file_upload. - save_error = handle_upload_file(file, filename, session.meeting, 'slides', request=request, encoding=form.file_encoding[file.name]) + save_error = handle_upload_file( + file, + filename, + session.meeting, + "slides", + request=request, + encoding=form.file_encoding[file.name], + ) if save_error: form.add_error(None, save_error) else: doc.save_with_history([e]) post_process(doc) + + # Send MeetEcho updates even if we had a problem saving - that will keep it in sync with the + # SessionPresentation, which was already saved regardless of problems saving the file. + if hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + for sp in added_presentations: + try: + sm.add(session=sp.session, slides=doc, order=sp.order) + except MeetechoAPIError as err: + log(f"Error in SlidesManager.add(): {err}") + for sp in revised_presentations: + try: + sm.revise(session=sp.session, slides=doc) + except MeetechoAPIError as err: + log(f"Error in SlidesManager.revise(): {err}") + + if not save_error: messages.success( request, - f'Successfully uploaded slides as revision {doc.rev} of {doc.name}.') - return redirect('ietf.meeting.views.session_details',num=num,acronym=session.group.acronym) - else: + f"Successfully uploaded slides as revision {doc.rev} of {doc.name}.", + ) + return redirect( + "ietf.meeting.views.session_details", + num=num, + acronym=session.group.acronym, + ) + else: initial = {} - if slides: - initial = {'title':slides.title} + if doc is not None: + initial = {"title": doc.title} form = UploadSlidesForm(session, show_apply_to_all_checkbox, initial=initial) - return render(request, "meeting/upload_session_slides.html", - {'session': session, - 'session_number': session_number, - 'slides_sp' : slides_sp, - 'form': form, - }) + return render( + request, + "meeting/upload_session_slides.html", + { + "session": session, + "session_number": session_number, + "slides_sp": session.presentations.filter(document=doc).first() if doc else None, + "form": form, + }, + ) + + @login_required def propose_session_slides(request, session_id, num): session = get_object_or_404(Session,pk=session_id) @@ -3031,135 +3154,284 @@ def propose_session_slides(request, session_id, num): 'form': form, }) + def remove_sessionpresentation(request, session_id, num, name): - sp = get_object_or_404(SessionPresentation,session_id=session_id,document__name=name) + sp = get_object_or_404( + SessionPresentation, session_id=session_id, document__name=name + ) session = sp.session if not session.can_manage_materials(request.user): - permission_denied(request, "You don't have permission to manage materials for this session.") - if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"): - permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.") - if request.method == 'POST': + permission_denied( + request, "You don't have permission to manage materials for this session." + ) + if session.is_material_submission_cutoff() and not has_role( + request.user, "Secretariat" + ): + permission_denied( + request, + "The materials cutoff for this session has passed. Contact the secretariat for further action.", + ) + if request.method == "POST": session.presentations.filter(pk=sp.pk).delete() - c = DocEvent(type="added_comment", doc=sp.document, rev=sp.document.rev, by=request.user.person) + c = DocEvent( + type="added_comment", + doc=sp.document, + rev=sp.document.rev, + by=request.user.person, + ) c.desc = "Removed from session: %s" % (session) c.save() - messages.success(request, f'Successfully removed {name}.') - return redirect('ietf.meeting.views.session_details', num=session.meeting.number, acronym=session.group.acronym) + messages.success(request, f"Successfully removed {name}.") + if sp.document.type_id == "slides" and hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.delete(session=session, slides=sp.document) + except MeetechoAPIError as err: + log(f"Error in SlidesManager.delete(): {err}") + + return redirect( + "ietf.meeting.views.session_details", + num=session.meeting.number, + acronym=session.group.acronym, + ) + + return render(request, "meeting/remove_sessionpresentation.html", {"sp": sp}) - return render(request,'meeting/remove_sessionpresentation.html', {'sp': sp }) def ajax_add_slides_to_session(request, session_id, num): - session = get_object_or_404(Session,pk=session_id) + session = get_object_or_404(Session, pk=session_id) if not session.can_manage_materials(request.user): - permission_denied(request, "You don't have permission to upload slides for this session.") - if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"): - permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.") + permission_denied( + request, "You don't have permission to upload slides for this session." + ) + if session.is_material_submission_cutoff() and not has_role( + request.user, "Secretariat" + ): + permission_denied( + request, + "The materials cutoff for this session has passed. Contact the secretariat for further action.", + ) - if request.method != 'POST' or not request.POST: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'No data submitted or not POST' }),content_type='application/json') + if request.method != "POST" or not request.POST: + return HttpResponse( + json.dumps({"success": False, "error": "No data submitted or not POST"}), + content_type="application/json", + ) - order_str = request.POST.get('order', None) + order_str = request.POST.get("order", None) try: order = int(order_str) except (ValueError, TypeError): - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied order is not valid' }),content_type='application/json') - if order < 1 or order > session.presentations.filter(document__type_id='slides').count() + 1 : - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied order is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied order is not valid"}), + content_type="application/json", + ) + if ( + order < 1 + or order > session.presentations.filter(document__type_id="slides").count() + 1 + ): + return HttpResponse( + json.dumps({"success": False, "error": "Supplied order is not valid"}), + content_type="application/json", + ) - name = request.POST.get('name', None) + name = request.POST.get("name", None) doc = Document.objects.filter(name=name).first() if not doc: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied name is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied name is not valid"}), + content_type="application/json", + ) if not session.presentations.filter(document=doc).exists(): condition_slide_order(session) - session.presentations.filter(document__type_id='slides', order__gte=order).update(order=F('order')+1) - session.presentations.create(document=doc,rev=doc.rev,order=order) - DocEvent.objects.create(type="added_comment", doc=doc, rev=doc.rev, by=request.user.person, desc="Added to session: %s" % session) + session.presentations.filter( + document__type_id="slides", order__gte=order + ).update(order=F("order") + 1) + session.presentations.create(document=doc, rev=doc.rev, order=order) + DocEvent.objects.create( + type="added_comment", + doc=doc, + rev=doc.rev, + by=request.user.person, + desc="Added to session: %s" % session, + ) - return HttpResponse(json.dumps({'success':True}), content_type='application/json') + # Notify Meetecho of new slides if the API is configured + if hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.add(session=session, slides=doc, order=order) + except MeetechoAPIError as err: + log(f"Error in SlidesManager.add(): {err}") + + return HttpResponse(json.dumps({"success": True}), content_type="application/json") def ajax_remove_slides_from_session(request, session_id, num): - session = get_object_or_404(Session,pk=session_id) + session = get_object_or_404(Session, pk=session_id) if not session.can_manage_materials(request.user): - permission_denied(request, "You don't have permission to upload slides for this session.") - if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"): - permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.") + permission_denied( + request, "You don't have permission to upload slides for this session." + ) + if session.is_material_submission_cutoff() and not has_role( + request.user, "Secretariat" + ): + permission_denied( + request, + "The materials cutoff for this session has passed. Contact the secretariat for further action.", + ) - if request.method != 'POST' or not request.POST: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'No data submitted or not POST' }),content_type='application/json') + if request.method != "POST" or not request.POST: + return HttpResponse( + json.dumps({"success": False, "error": "No data submitted or not POST"}), + content_type="application/json", + ) - oldIndex_str = request.POST.get('oldIndex', None) + oldIndex_str = request.POST.get("oldIndex", None) try: oldIndex = int(oldIndex_str) except (ValueError, TypeError): - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') - if oldIndex < 1 or oldIndex > session.presentations.filter(document__type_id='slides').count() : - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) + if ( + oldIndex < 1 + or oldIndex > session.presentations.filter(document__type_id="slides").count() + ): + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) - name = request.POST.get('name', None) + name = request.POST.get("name", None) doc = Document.objects.filter(name=name).first() if not doc: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied name is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied name is not valid"}), + content_type="application/json", + ) condition_slide_order(session) affected_presentations = session.presentations.filter(document=doc).first() if affected_presentations: if affected_presentations.order == oldIndex: affected_presentations.delete() - session.presentations.filter(document__type_id='slides', order__gt=oldIndex).update(order=F('order')-1) - DocEvent.objects.create(type="added_comment", doc=doc, rev=doc.rev, by=request.user.person, desc="Removed from session: %s" % session) - return HttpResponse(json.dumps({'success':True}), content_type='application/json') + session.presentations.filter( + document__type_id="slides", order__gt=oldIndex + ).update(order=F("order") - 1) + DocEvent.objects.create( + type="added_comment", + doc=doc, + rev=doc.rev, + by=request.user.person, + desc="Removed from session: %s" % session, + ) + # Notify Meetecho of removed slides if the API is configured + if hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.delete(session=session, slides=doc) + except MeetechoAPIError as err: + log(f"Error in SlidesManager.delete(): {err}") + # Report success + return HttpResponse( + json.dumps({"success": True}), content_type="application/json" + ) else: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Name does not match index' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Name does not match index"}), + content_type="application/json", + ) else: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'SessionPresentation not found' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "SessionPresentation not found"}), + content_type="application/json", + ) def ajax_reorder_slides_in_session(request, session_id, num): - session = get_object_or_404(Session,pk=session_id) + session = get_object_or_404(Session, pk=session_id) if not session.can_manage_materials(request.user): - permission_denied(request, "You don't have permission to upload slides for this session.") - if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"): - permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.") + permission_denied( + request, "You don't have permission to upload slides for this session." + ) + if session.is_material_submission_cutoff() and not has_role( + request.user, "Secretariat" + ): + permission_denied( + request, + "The materials cutoff for this session has passed. Contact the secretariat for further action.", + ) - if request.method != 'POST' or not request.POST: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'No data submitted or not POST' }),content_type='application/json') + if request.method != "POST" or not request.POST: + return HttpResponse( + json.dumps({"success": False, "error": "No data submitted or not POST"}), + content_type="application/json", + ) - num_slides_in_session = session.presentations.filter(document__type_id='slides').count() - oldIndex_str = request.POST.get('oldIndex', None) + session_slides = session.presentations.filter(document__type_id="slides") + num_slides_in_session = session_slides.count() + oldIndex_str = request.POST.get("oldIndex", None) try: oldIndex = int(oldIndex_str) except (ValueError, TypeError): - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') - if oldIndex < 1 or oldIndex > num_slides_in_session : - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) + if oldIndex < 1 or oldIndex > num_slides_in_session: + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) - newIndex_str = request.POST.get('newIndex', None) + newIndex_str = request.POST.get("newIndex", None) try: newIndex = int(newIndex_str) except (ValueError, TypeError): - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') - if newIndex < 1 or newIndex > num_slides_in_session : - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) + if newIndex < 1 or newIndex > num_slides_in_session: + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) if newIndex == oldIndex: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) condition_slide_order(session) - sp = session.presentations.get(order=oldIndex) + sp = session_slides.get(order=oldIndex) if oldIndex < newIndex: - session.presentations.filter(order__gt=oldIndex, order__lte=newIndex).update(order=F('order')-1) + session_slides.filter(order__gt=oldIndex, order__lte=newIndex).update( + order=F("order") - 1 + ) else: - session.presentations.filter(order__gte=newIndex, order__lt=oldIndex).update(order=F('order')+1) + session_slides.filter(order__gte=newIndex, order__lt=oldIndex).update( + order=F("order") + 1 + ) sp.order = newIndex sp.save() - return HttpResponse(json.dumps({'success':True}), content_type='application/json') + # Update slide order with Meetecho if the API is configured + if hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.send_update(session) + except MeetechoAPIError as err: + log(f"Error in SlidesManager.send_update(): {err}") + + return HttpResponse(json.dumps({"success": True}), content_type="application/json") @role_required('Secretariat') @@ -3847,6 +4119,8 @@ def organize_proceedings_sessions(sessions): 'drafts': _format_materials((s, s.drafts()) for s in ss), 'last_update': session.last_update if hasattr(session, 'last_update') else None } + if session and session.meeting.type_id == 'ietf' and not session.meeting.proceedings_final: + entry['attendances'] = _format_materials((s, s) for s in ss if Attended.objects.filter(session=s).exists()) if is_meeting: meeting_groups.append(entry) else: @@ -3941,12 +4215,11 @@ def proceedings(request, num=None): def finalize_proceedings(request, num=None): meeting = get_meeting(num) - if (meeting.number.isdigit() and int(meeting.number) <= 64) or not meeting.schedule or not meeting.schedule.assignments.exists() or meeting.proceedings_final: raise Http404 if request.method=='POST': - finalize(meeting) + finalize(request, meeting) return HttpResponseRedirect(reverse('ietf.meeting.views.proceedings',kwargs={'num':meeting.number})) return render(request, "meeting/finalize.html", {'meeting':meeting,}) @@ -4150,37 +4423,111 @@ def deprecated_api_set_session_video_url(request): return HttpResponse("Done", status=200, content_type='text/plain') + @require_api_key @role_required('Recording Manager') # TODO : Rework how Meetecho interacts via APIs. There may be better paths to pursue than Personal API keys as they are currently defined. @csrf_exempt def api_add_session_attendees(request): + """Upload attendees for one or more sessions + + parameters: + apikey: the poster's personal API key + attended: json blob with + { + "session_id": session pk, + "attendees": [ + {"user_id": user-pk-1, "join_time": "2024-02-21T18:00:00Z"}, + {"user_id": user-pk-2, "join_time": "2024-02-21T18:00:01Z"}, + {"user_id": user-pk-3, "join_time": "2024-02-21T18:00:02Z"}, + ... + ] + } + """ + json_validator = jsonschema.Draft202012Validator( + schema={ + "type": "object", + "properties": { + "session_id": {"type": "integer"}, + "attendees": { + # Allow either old or new format until after IETF 119 + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, # old: array of user PKs + { + # new: array of user_id / join_time objects + "type": "array", + "items": { + "type": "object", + "properties": { + "user_id": {"type": "integer", }, + "join_time": {"type": "string", "format": "date-time"} + }, + "required": ["user_id", "join_time"], + }, + }, + ], + } + }, + "required": ["session_id", "attendees"], + }, + format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER, # format-checks disabled by default + ) def err(code, text): - return HttpResponse(text, status=code, content_type='text/plain') + return HttpResponse(text, status=code, content_type="text/plain") - if request.method != 'POST': + if request.method != "POST": return err(405, "Method not allowed") - attended_post = request.POST.get('attended') + attended_post = request.POST.get("attended") if not attended_post: return err(400, "Missing attended parameter") + + # Validate the request payload try: - attended = json.loads(attended_post) - except json.decoder.JSONDecodeError: - return err(400, "Malformed post") - if not ( 'session_id' in attended and type(attended['session_id']) is int ): - return err(400, "Malformed post") - session_id = attended['session_id'] - if not ( 'attendees' in attended and type(attended['attendees']) is list and all([type(el) is int for el in attended['attendees']]) ): + payload = json.loads(attended_post) + json_validator.validate(payload) + except (json.decoder.JSONDecodeError, jsonschema.exceptions.ValidationError): return err(400, "Malformed post") + + session_id = payload["session_id"] session = Session.objects.filter(pk=session_id).first() if not session: return err(400, "Invalid session") - users = User.objects.filter(pk__in=attended['attendees']) - if users.count() != len(attended['attendees']): - return err(400, "Invalid attendee") - for user in users: - session.attended_set.get_or_create(person=user.person) - return HttpResponse("Done", status=200, content_type='text/plain') + + attendees = payload["attendees"] + if len(attendees) > 0: + # Check whether we have old or new format + if type(attendees[0]) == int: + # it's the old format + users = User.objects.filter(pk__in=attendees) + if users.count() != len(payload["attendees"]): + return err(400, "Invalid attendee") + for user in users: + session.attended_set.get_or_create(person=user.person) + else: + # it's the new format + join_time_by_pk = { + att["user_id"]: datetime.datetime.fromisoformat( + att["join_time"].replace("Z", "+00:00") # Z not understood until py311 + ) + for att in attendees + } + persons = list(Person.objects.filter(user__pk__in=join_time_by_pk)) + if len(persons) != len(join_time_by_pk): + return err(400, "Invalid attendee") + to_create = [ + Attended(session=session, person=person, time=join_time_by_pk[person.user_id]) + for person in persons + ] + # Create in bulk, ignoring any that already exist + Attended.objects.bulk_create(to_create, ignore_conflicts=True) + + if session.meeting.type_id == "interim": + save_error = generate_bluesheet(request, session) + if save_error: + return err(400, save_error) + + return HttpResponse("Done", status=200, content_type="text/plain") + @require_api_key @role_required('Recording Manager') diff --git a/ietf/settings.py b/ietf/settings.py index 57e6f20bd..dca3fb132 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -1173,6 +1173,10 @@ CELERY_TASK_IGNORE_RESULT = True # ignore results unless specifically enabled f # 'client_id': 'datatracker', # 'client_secret': 'some secret', # 'request_timeout': 3.01, # python-requests doc recommend slightly > a multiple of 3 seconds +# # How many minutes before/after session to enable slide update API. Defaults to 15. Set to None to disable, +# # or < 0 to _always_ send updates (useful for debugging) +# 'slides_notify_time': 15, +# 'debug': False, # if True, API calls will be echoed as debug instead of sent (only works for slides for now) # } # Meetecho URLs - instantiate with url.format(session=some_session) diff --git a/ietf/stats/models.py b/ietf/stats/models.py index 699334392..66e359f50 100644 --- a/ietf/stats/models.py +++ b/ietf/stats/models.py @@ -66,7 +66,10 @@ class MeetingRegistration(models.Model): email = models.EmailField(blank=True, null=True) reg_type = models.CharField(blank=True, max_length=255) ticket_type = models.CharField(blank=True, max_length=255) + # attended was used prior to the introduction of the ietf.meeting.Attended model and is still used by + # Meeting.get_attendance() for older meetings. It should not be used except for dealing with legacy data. attended = models.BooleanField(default=False) + # checkedin indicates that the badge was picked up checkedin = models.BooleanField(default=False) def __str__(self): diff --git a/ietf/templates/doc/review/complete_review.html b/ietf/templates/doc/review/complete_review.html index 370e311c7..2319c40fd 100644 --- a/ietf/templates/doc/review/complete_review.html +++ b/ietf/templates/doc/review/complete_review.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{# Copyright The IETF Trust 2016, All Rights Reserved #} +{# Copyright The IETF Trust 2016-2024, All Rights Reserved #} {% load origin django_bootstrap5 static person_filters textfilters %} {% block title %} {% if revising_review %} @@ -96,7 +96,7 @@ {% if mail_archive_query_urls %}