diff --git a/.github/workflows/build-base-app.yml b/.github/workflows/build-base-app.yml index 5f0a0d11b8..479cd7cadf 100644 --- a/.github/workflows/build-base-app.yml +++ b/.github/workflows/build-base-app.yml @@ -59,7 +59,7 @@ jobs: echo "${{ env.IMGVERSION }}" > dev/build/TARGET_BASE - name: Commit CHANGELOG.md - uses: stefanzweifel/git-auto-commit-action@v5 + uses: stefanzweifel/git-auto-commit-action@v6 with: branch: ${{ github.ref_name }} commit_message: 'ci: update base image target version to ${{ env.IMGVERSION }}' diff --git a/dev/build/Dockerfile b/dev/build/Dockerfile index 3ad11e4d9e..8317195446 100644 --- a/dev/build/Dockerfile +++ b/dev/build/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/ietf-tools/datatracker-app-base:20250514T1627 +FROM ghcr.io/ietf-tools/datatracker-app-base:20250624T1543 LABEL maintainer="IETF Tools Team " ENV DEBIAN_FRONTEND=noninteractive diff --git a/dev/build/TARGET_BASE b/dev/build/TARGET_BASE index 4bd4db561e..1f2e39a0a2 100644 --- a/dev/build/TARGET_BASE +++ b/dev/build/TARGET_BASE @@ -1 +1 @@ -20250514T1627 +20250624T1543 diff --git a/docker/README.md b/docker/README.md index f2161a173f..0ca79a6e89 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,5 +1,18 @@ # Datatracker Development in Docker +- [Getting started](#getting-started) +- [Using Visual Studio Code](#using-visual-studio-code) + - [Initial Setup](#initial-setup) + - [Subsequent Launch](#subsequent-launch) + - [Usage](#usage) +- [Using Other Editors / Generic](#using-other-editors--generic) + - [Exit Environment](#exit-environment) + - [Accessing PostgreSQL Port](#accessing-postgresql-port) +- [Clean and Rebuild DB from latest image](#clean-and-rebuild-db-from-latest-image) +- [Clean all](#clean-all) +- [Updating an older environment](#updating-an-older-environment) +- [Notes / Troubleshooting](#notes--troubleshooting) + ## Getting started 1. [Set up Docker](https://docs.docker.com/get-started/) on your preferred platform. On Windows, it is highly recommended to use the [WSL 2 *(Windows Subsystem for Linux)*](https://docs.docker.com/desktop/windows/wsl/) backend. @@ -123,7 +136,14 @@ docker compose down to terminate the containers. -### Clean and Rebuild DB from latest image +### Accessing PostgreSQL Port + +The port is exposed but not automatically mapped to `5432` to avoid potential conflicts with the host. To get the mapped port, run the command *(from the project `/docker` directory)*: +```sh +docker compose port db 5432 +``` + +## Clean and Rebuild DB from latest image To delete the active DB container, its volume and get the latest image / DB dump, simply run the following command: @@ -141,7 +161,7 @@ docker compose pull db docker compose build --no-cache db ``` -### Clean all +## Clean all To delete all containers for this project, its associated images and purge any remaining dangling images, simply run the following command: @@ -157,17 +177,20 @@ On Windows: docker compose down -v --rmi all docker image prune ``` -### Updating an older environment + +## Updating an older environment If you already have a clone, such as from a previous codesprint, and are updating that clone, before starting the datatracker from the updated image: -* rm ietf/settings_local.py # The startup script will put a new one, appropriate to the current release, in place -* Execute the `Clean all` sequence above. +1. `rm ietf/settings_local.py` *(The startup script will put a new one, appropriate to the current release, in place)* +1. Execute the [Clean all](#clean-all) sequence above. -### Accessing PostgreSQL Port +If the dev environment fails to start, even after running the [Clean all](#clean-all) sequence above, you can fully purge all docker cache, containers, images and volumes by running the command below. + +> [!CAUTION] +> Note that this will delete everything docker-related, including non-datatracker docker resources you might have. -The port is exposed but not automatically mapped to `5432` to avoid potential conflicts with the host. To get the mapped port, run the command *(from the project `/docker` directory)*: ```sh -docker compose port db 5432 +docker system prune -a --volumes ``` ## Notes / Troubleshooting diff --git a/ietf/group/migrations/0005_remove_sdo_authorized_individuals.py b/ietf/group/migrations/0005_remove_sdo_authorized_individuals.py new file mode 100644 index 0000000000..77fe25b467 --- /dev/null +++ b/ietf/group/migrations/0005_remove_sdo_authorized_individuals.py @@ -0,0 +1,192 @@ +# Copyright The IETF Trust 2025, All Rights Reserved + +from collections import defaultdict + +from django.db import migrations + +from ietf.person.name import plain_name + + +def get_plain_name(person): + return person.plain or plain_name(person.name) + + +def forward(apps, schema_editor): + """Remove any 'auth' Role objects for groups of type 'sdo' + + The IAB has decided that the Authorized Individual concept for + authorizing entry or management of liaison statments hasn't worked + well - the roles for the groups are not being maintained, Instead, + the concept will be removed and the liaison managers or secretariat + (and soon the liaison coordinators) will operate the liaison tool + on their behalf. + """ + Role = apps.get_model("group", "Role") + GroupEvent = apps.get_model("group", "GroupEvent") + groups = defaultdict(list) + role_qs = Role.objects.filter(name_id="auth", group__type_id="sdo") + for role in role_qs: + groups[role.group].append(role) + for group in groups: + desc = f"Removed Authorized Persons: {', '.join([get_plain_name(role.person) for role in groups[group]])}" + GroupEvent.objects.create( + group=group, + by_id=1, # (System) + desc=desc, + ) + role_qs.delete() + + +def reverse(apps, schema_editor): + """Intentionally does nothing""" + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("group", "0004_modern_list_archive"), + ] + + operations = [migrations.RunPython(forward, reverse)] + + +# At the time this migration was created, it would have removed these Role objects: +# { "authorized_individuals" : [ +# {"person_id": 107937, "group_id": 56, "email": "hannu.hietalahti@nokia.com" }, # Hannu Hietalahti is Authorized Individual in 3gpp +# {"person_id": 107943, "group_id": 56, "email": "3GPPLiaison@etsi.org" }, # Susanna Kooistra is Authorized Individual in 3gpp +# {"person_id": 112807, "group_id": 56, "email": "Paolo.Usai@etsi.org" }, # Paolo Usai is Authorized Individual in 3gpp +# {"person_id": 105859, "group_id": 56, "email": "atle.monrad@ericsson.com" }, # Atle Monrad is Authorized Individual in 3gpp +# {"person_id": 116149, "group_id": 1907, "email": "tsgsx_chair@3GPP2.org" }, # Xiaowu Zhao is Authorized Individual in 3gpp2-tsg-sx +# {"person_id": 120914, "group_id": 1902, "email": "ozgur.oyman@intel.com" }, # Ozgur Oyman is Authorized Individual in 3gpp-tsgsa-sa4 +# {"person_id": 107943, "group_id": 1902, "email": "3GPPLiaison@etsi.org" }, # Susanna Kooistra is Authorized Individual in 3gpp-tsgsa-sa4 +# {"person_id": 119203, "group_id": 1902, "email": "fanyanping@huawei.com" }, # Yanping Fan is Authorized Individual in 3gpp-tsgsa-sa4 +# {"person_id": 112977, "group_id": 1902, "email": "tomas.frankkila@ericsson.com" }, # Tomas Frankkila is Authorized Individual in 3gpp-tsgsa-sa4 +# {"person_id": 120240, "group_id": 2019, "email": "CM8655@att.com" }, # Peter Musgrove is Authorized Individual in atis-eloc-tf +# {"person_id": 120241, "group_id": 2019, "email": "Christian.Militeau@intrado.com" }, # Christian Militeau is Authorized Individual in atis-eloc-tf +# {"person_id": 120243, "group_id": 2019, "email": "ablasgen@atis.org" }, # Alexandra Blasgen is Authorized Individual in atis-eloc-tf +# {"person_id": 114696, "group_id": 67, "email": "KEN.KO@adtran.com" }, # Ken Ko is Authorized Individual in broadband-forum +# {"person_id": 119494, "group_id": 67, "email": "michael.fargano@centurylink.com" }, # Michael Fargano is Authorized Individual in broadband-forum +# {"person_id": 124318, "group_id": 67, "email": "joey.boyd@adtran.com" }, # Joey Boyd is Authorized Individual in broadband-forum +# {"person_id": 114762, "group_id": 67, "email": "bwelch@juniper.net" }, # Bill Welch is Authorized Individual in broadband-forum +# {"person_id": 112837, "group_id": 67, "email": "christophe.alter@orange.com" }, # Christophe Alter is Authorized Individual in broadband-forum +# {"person_id": 141083, "group_id": 2407, "email": "dan.middleton@intel.com" }, # Dan Middleton is Authorized Individual in confidential-computing-consortium +# {"person_id": 117421, "group_id": 1933, "email": "chairman@dmtf.org" }, # Winston Bumpus is Authorized Individual in dmtf +# {"person_id": 116529, "group_id": 1919, "email": "istvan@ecma-international.org" }, # Istvan Sebestyen is Authorized Individual in ecma-tc39 +# {"person_id": 116363, "group_id": 1915, "email": "e2nasupport@etsi.org" }, # Sonia Compans is Authorized Individual in etsi-e2na +# {"person_id": 116862, "group_id": 2003, "email": "latif@ladid.lu" }, # Latif Ladid is Authorized Individual in etsi-isg-ip6 +# {"person_id": 116283, "group_id": 2198, "email": "adrian.neal@vodafone.com" }, # Adrian Neal is Authorized Individual in etsi-isg-mec +# {"person_id": 119412, "group_id": 2004, "email": "jkfernic@uwaterloo.ca" }, # Jennifer Fernick is Authorized Individual in etsi-isg-qsc +# {"person_id": 122406, "group_id": 2165, "email": "d.lake@surrey.ac.uk" }, # David Lake is Authorized Individual in etsi-ngp +# {"person_id": 122407, "group_id": 2165, "email": "andy.sutton@ee.co.uk" }, # Andy Sutton is Authorized Individual in etsi-ngp +# {"person_id": 112609, "group_id": 2165, "email": "richard.li@futurewei.com" }, # Richard Li is Authorized Individual in etsi-ngp +# {"person_id": 122406, "group_id": 2177, "email": "d.lake@surrey.ac.uk" }, # David Lake is Authorized Individual in etsi-ngp-isp +# {"person_id": 112609, "group_id": 2177, "email": "richard.li@futurewei.com" }, # Richard Li is Authorized Individual in etsi-ngp-isp +# {"person_id": 122407, "group_id": 2177, "email": "andy.sutton@ee.co.uk" }, # Andy Sutton is Authorized Individual in etsi-ngp-isp +# {"person_id": 118527, "group_id": 1986, "email": "luca.pesando@telecomitalia.it" }, # Luca Pesando is Authorized Individual in etsi-ntech +# {"person_id": 118526, "group_id": 1986, "email": "NTECHsupport@etsi.org" }, # Sylwia Korycinska is Authorized Individual in etsi-ntech +# {"person_id": 116052, "group_id": 1904, "email": "Beniamino.gorini@alcatel-lucent.com" }, # Gorini Beniamino is Authorized Individual in etsi-tc-ee +# {"person_id": 19651, "group_id": 63, "email": "glenn.parsons@ericsson.com" }, # Glenn Parsons is Authorized Individual in ieee-802-1 +# {"person_id": 107599, "group_id": 63, "email": "tony@jeffree.co.uk" }, # Tony Jeffree is Authorized Individual in ieee-802-1 +# {"person_id": 117415, "group_id": 1862, "email": "Adrian.P.Stephens@intel.com" }, # Adrian Stephens is Authorized Individual in ieee-802-11 +# {"person_id": 106284, "group_id": 1862, "email": "dstanley@arubanetworks.com" }, # Dorothy Stanley is Authorized Individual in ieee-802-11 +# {"person_id": 114106, "group_id": 1871, "email": "r.b.marks@ieee.org" }, # Roger Marks is Authorized Individual in ieee-802-16 +# {"person_id": 101753, "group_id": 1885, "email": "max.riegel@ieee.org" }, # Max Riegel is Authorized Individual in ieee-802-ec-omniran +# {"person_id": 113810, "group_id": 1859, "email": "jehrig@inventures.com" }, # John Ehrig is Authorized Individual in imtc +# {"person_id": 123010, "group_id": 48, "email": "Emil.Kowalczyk@orange.com" }, # Emil Kowalczyk is Authorized Individual in iso-iec-jtc1-sc2 +# {"person_id": 11182, "group_id": 48, "email": "paf@netnod.se" }, # Patrik Fältström is Authorized Individual in iso-iec-jtc1-sc2 +# {"person_id": 117429, "group_id": 1939, "email": "krystyna.passia@din.de" }, # Krystyna Passia is Authorized Individual in iso-iec-jtc1-sc27 +# {"person_id": 117428, "group_id": 1939, "email": "walter.fumy@bdr.de" }, # Walter Fumy is Authorized Individual in iso-iec-jtc1-sc27 +# {"person_id": 114435, "group_id": 74, "email": "watanabe@itscj.ipsj.or.jp" }, # Shinji Watanabe is Authorized Individual in iso-iec-jtc1-sc29-wg11 +# {"person_id": 112106, "group_id": 49, "email": "jooran@kisi.or.kr" }, # Jooran Lee is Authorized Individual in iso-iec-jtc1-sc6 +# {"person_id": 17037, "group_id": 49, "email": "dykim@comsun.chungnnam.ac.kr" }, # Dae Kim is Authorized Individual in iso-iec-jtc1-sc6 +# {"person_id": 117426, "group_id": 1938, "email": "chair@jtc1-sc7.org" }, # Francois Coallier is Authorized Individual in iso-iec-jtc1-sc7 +# {"person_id": 117427, "group_id": 1938, "email": "secretariat@jtc1-sc7.org" }, # Witold Suryn is Authorized Individual in iso-iec-jtc1-sc7 +# {"person_id": 118769, "group_id": 2144, "email": "alexandre.petrescu@gmail.com" }, # Alexandre Petrescu is Authorized Individual in isotc204 +# {"person_id": 115544, "group_id": 1890, "email": "sergio.buonomo@itu.int" }, # Sergio Buonomo is Authorized Individual in itu-r +# {"person_id": 122111, "group_id": 2157, "email": "h.mazar@atdi.com" }, # Haim Mazar is Authorized Individual in itu-r-wp-5c +# {"person_id": 115544, "group_id": 2157, "email": "sergio.buonomo@itu.int" }, # Sergio Buonomo is Authorized Individual in itu-r-wp-5c +# {"person_id": 112105, "group_id": 51, "email": "Malcolm.Johnson@itu.int" }, # Malcom Johnson is Authorized Individual in itu-t +# {"person_id": 113911, "group_id": 1860, "email": "martin.adolph@itu.int" }, # Martin Adolph is Authorized Individual in itu-t-fg-dist +# {"person_id": 122779, "group_id": 2180, "email": "Leo.Lehmann@bakom.admin.ch" }, # Leo Lehmann is Authorized Individual in itu-t-fg-imt-2020 +# {"person_id": 103383, "group_id": 2180, "email": "peter.ashwoodsmith@huawei.com" }, # Peter Ashwood-Smith is Authorized Individual in itu-t-fg-imt-2020 +# {"person_id": 107300, "group_id": 1872, "email": "tatiana.kurakova@itu.int" }, # Tatiana Kurakova is Authorized Individual in itu-t-jca-cloud +# {"person_id": 106224, "group_id": 1872, "email": "mmorrow@cisco.com" }, # Monique Morrow is Authorized Individual in itu-t-jca-cloud +# {"person_id": 105714, "group_id": 1874, "email": "martin.euchner@itu.int" }, # Martin Euchner is Authorized Individual in itu-t-jca-cop +# {"person_id": 106475, "group_id": 2170, "email": "khj@etri.re.kr" }, # Hyoung-Jun Kim is Authorized Individual in itu-t-jca-iot-scc +# {"person_id": 122491, "group_id": 2170, "email": "tsbjcaiot@itu.int" }, # ITU Tsb is Authorized Individual in itu-t-jca-iot-scc +# {"person_id": 122490, "group_id": 2170, "email": "fabio.bigi@virgilio.it" }, # Fabio Bigi is Authorized Individual in itu-t-jca-iot-scc +# {"person_id": 116952, "group_id": 1927, "email": "chengying10@chinaunicom.cn" }, # Ying Cheng is Authorized Individual in itu-t-jca-sdn +# {"person_id": 111205, "group_id": 1927, "email": "t-egawa@ct.jp.nec.com" }, # Takashi Egawa is Authorized Individual in itu-t-jca-sdn +# {"person_id": 107298, "group_id": 2178, "email": "tsbsg11@itu.int" }, # Arshey Odedra is Authorized Individual in itu-tsbsg-11 +# {"person_id": 107300, "group_id": 77, "email": "tatiana.kurakova@itu.int" }, # Tatiana Kurakova is Authorized Individual in itu-t-sg-11 +# {"person_id": 112573, "group_id": 77, "email": "stefano.polidori@itu.int" }, # Stefano Polidori is Authorized Individual in itu-t-sg-11 +# {"person_id": 115401, "group_id": 84, "email": "spennock@rim.com" }, # Scott Pennock is Authorized Individual in itu-t-sg-12 +# {"person_id": 114255, "group_id": 84, "email": "hiroshi.ota@itu.int" }, # Hiroshi Ota is Authorized Individual in itu-t-sg-12 +# {"person_id": 113032, "group_id": 84, "email": "catherine.quinquis@orange.com" }, # Catherine Quinquis is Authorized Individual in itu-t-sg-12 +# {"person_id": 113031, "group_id": 84, "email": "gunilla.berndtsson@ericsson.com" }, # Gunilla Berndtsson is Authorized Individual in itu-t-sg-12 +# {"person_id": 113672, "group_id": 84, "email": "sarah.scott@itu.int" }, # Sarah Scott is Authorized Individual in itu-t-sg-12 +# {"person_id": 122459, "group_id": 81, "email": "chan@etri.re.kr" }, # Kangchan Lee is Authorized Individual in itu-t-sg-13 +# {"person_id": 107300, "group_id": 81, "email": "tatiana.kurakova@itu.int" }, # Tatiana Kurakova is Authorized Individual in itu-t-sg-13 +# {"person_id": 109145, "group_id": 62, "email": "lihan@chinamobile.com" }, # Han Li is Authorized Individual in itu-t-sg-15 +# {"person_id": 115875, "group_id": 62, "email": "mark.jones@xtera.com" }, # Mark Jones is Authorized Individual in itu-t-sg-15 +# {"person_id": 115846, "group_id": 62, "email": "peter.stassar@huawei.com" }, # Peter Stassar is Authorized Individual in itu-t-sg-15 +# {"person_id": 123452, "group_id": 62, "email": "sshew@ciena.com" }, # Stephen Shew is Authorized Individual in itu-t-sg-15 +# {"person_id": 109312, "group_id": 62, "email": "huubatwork@gmail.com" }, # Huub van Helvoort is Authorized Individual in itu-t-sg-15 +# {"person_id": 115874, "group_id": 62, "email": "tom.huber@tellabs.com" }, # Tom Huber is Authorized Individual in itu-t-sg-15 +# {"person_id": 110799, "group_id": 62, "email": "koike.yoshinori@lab.ntt.co.jp" }, # Yoshinori Koike is Authorized Individual in itu-t-sg-15 +# {"person_id": 110831, "group_id": 62, "email": "kam.lam@nokia.com" }, # Hing-Kam Lam is Authorized Individual in itu-t-sg-15 +# {"person_id": 114255, "group_id": 62, "email": "hiroshi.ota@itu.int" }, # Hiroshi Ota is Authorized Individual in itu-t-sg-15 +# {"person_id": 115874, "group_id": 62, "email": "tom.huber@coriant.com" }, # Tom Huber is Authorized Individual in itu-t-sg-15 +# {"person_id": 123014, "group_id": 62, "email": "jessy.rouyer@nokia.com" }, # Jessy Rouyer is Authorized Individual in itu-t-sg-15 +# {"person_id": 111160, "group_id": 62, "email": "ryoo@etri.re.kr" }, # Jeong-dong Ryoo is Authorized Individual in itu-t-sg-15 +# {"person_id": 107296, "group_id": 62, "email": "greg.jones@itu.int" }, # Greg Jones is Authorized Individual in itu-t-sg-15 +# {"person_id": 118539, "group_id": 72, "email": "rosa.angelesleondev@itu.int" }, # Rosa De Vivero is Authorized Individual in itu-t-sg-16 +# {"person_id": 123169, "group_id": 72, "email": "garysull@microsoft.com" }, # Gary Sullivan is Authorized Individual in itu-t-sg-16 +# {"person_id": 107746, "group_id": 72, "email": "hiwasaki.yusuke@lab.ntt.co.jp" }, # Yusuke Hiwasaki is Authorized Individual in itu-t-sg-16 +# {"person_id": 108160, "group_id": 1987, "email": "Christian.Groves@nteczone.com" }, # Christian Groves is Authorized Individual in itu-t-sg-16-q3 +# {"person_id": 118539, "group_id": 1987, "email": "rosa.angelesleondev@itu.int" }, # Rosa De Vivero is Authorized Individual in itu-t-sg-16-q3 +# {"person_id": 124354, "group_id": 76, "email": "jhbaek@kisa.or.kr" }, # Jonghyun Baek is Authorized Individual in itu-t-sg-17 +# {"person_id": 12898, "group_id": 1937, "email": "youki-k@is.aist-nara.ac.jp" }, # Youki Kadobayashi is Authorized Individual in itu-t-sg-17-q4 +# {"person_id": 113593, "group_id": 79, "email": "maite.comasbarnes@itu.int" }, # Maite Barnes is Authorized Individual in itu-t-sg-3 +# {"person_id": 122983, "group_id": 2000, "email": "cristina.bueti@itu.int" }, # Cristina Bueti is Authorized Individual in itu-t-sg-5 +# {"person_id": 112573, "group_id": 2072, "email": "stefano.polidori@itu.int" }, # Stefano Polidori is Authorized Individual in itu-t-sg-9 +# {"person_id": 113101, "group_id": 82, "email": "steve.trowbridge@alcatel-lucent.com" }, # Stephen Trowbridge is Authorized Individual in itu-t-tsag +# {"person_id": 20783, "group_id": 82, "email": "reinhard.scholl@itu.int" }, # Reinhard Scholl is Authorized Individual in itu-t-tsag +# {"person_id": 107300, "group_id": 1846, "email": "tatiana.kurakova@itu.int" }, # Tatiana Kurakova is Authorized Individual in itu-t-wp-5-13 +# {"person_id": 112107, "group_id": 69, "email": "michael.oreirdan@maawg.org" }, # Michael O'Reirdan is Authorized Individual in maawg +# {"person_id": 121870, "group_id": 75, "email": "liaisons@mef.net" }, # Liaison Mef is Authorized Individual in mef +# {"person_id": 112510, "group_id": 75, "email": "nan@mef.net" }, # Nan Chen is Authorized Individual in mef +# {"person_id": 124306, "group_id": 75, "email": "jason.wolfe@bell.ca" }, # WOLFE Jason is Authorized Individual in mef +# {"person_id": 114454, "group_id": 75, "email": "mike.bencheck@siamasystems.com" }, # Mike Bencheck is Authorized Individual in mef +# {"person_id": 115327, "group_id": 1888, "email": "klaus.moschner@ngmn.org" }, # Klaus Moschner is Authorized Individual in ngmn +# {"person_id": 123305, "group_id": 1888, "email": "office@ngmn.org" }, # Office Ngmn is Authorized Individual in ngmn +# {"person_id": 115160, "group_id": 1888, "email": "jminlee@sk.com" }, # Jongmin Lee is Authorized Individual in ngmn +# {"person_id": 117424, "group_id": 1936, "email": "patrick.gallagher@nist.gov" }, # Patrick Gallagher is Authorized Individual in nist +# {"person_id": 117431, "group_id": 1941, "email": "chet.ensign@xn--oasis-open-vt6e.org" }, # Chet Ensign is Authorized Individual in oasis +# {"person_id": 120913, "group_id": 2142, "email": "james.walker@tatacommunications.com" }, # James Walker is Authorized Individual in occ +# {"person_id": 6699, "group_id": 2142, "email": "dromasca@gmail.com" }, # Dan Romascanu is Authorized Individual in occ +# {"person_id": 118403, "group_id": 2142, "email": "richard.schell@verizon.com" }, # Rick Schell is Authorized Individual in occ +# {"person_id": 109676, "group_id": 83, "email": "Jonathan.Sadler@tellabs.com" }, # Jonathan Sadler is Authorized Individual in oif +# {"person_id": 122843, "group_id": 2122, "email": "tzhang@omaorg.org" }, # Tiffany Zhang is Authorized Individual in oma +# {"person_id": 116967, "group_id": 1947, "email": "JMudge@omaorg.org" }, # John Mudge is Authorized Individual in oma-architecture-wg +# {"person_id": 117423, "group_id": 1935, "email": "soley@omg.org" }, # Richard Soley is Authorized Individual in omg +# {"person_id": 110831, "group_id": 1858, "email": "kam.lam@nokia.com" }, # Hing-Kam Lam is Authorized Individual in onf +# {"person_id": 113674, "group_id": 1858, "email": "dan.pitt@opennetworking.org" }, # Dan Pitt is Authorized Individual in onf +# {"person_id": 118348, "group_id": 1984, "email": "dave.hood@ericsson.com" }, # Dave Hood is Authorized Individual in onf-arch-wg +# {"person_id": 116967, "group_id": 60, "email": "JMudge@omaorg.org" }, # John Mudge is Authorized Individual in open-mobile-alliance +# {"person_id": 112613, "group_id": 60, "email": "jerry.shih@att.com" }, # Jerry Shih is Authorized Individual in open-mobile-alliance +# {"person_id": 113067, "group_id": 60, "email": "laurent.goix@econocom.com" }, # Laurent Goix is Authorized Individual in open-mobile-alliance +# {"person_id": 112772, "group_id": 60, "email": "zhiyuan.hu@alcatel-sbell.com.cn" }, # Hu Zhiyuan is Authorized Individual in open-mobile-alliance +# {"person_id": 113064, "group_id": 60, "email": "thierry.berisot@telekom.de" }, # Thierry Berisot is Authorized Individual in open-mobile-alliance +# {"person_id": 124276, "group_id": 2212, "email": "jmisener@qti.qualcomm.com" }, # Jim Misener is Authorized Individual in sae-cell-v2x +# {"person_id": 124278, "group_id": 2212, "email": "Keith.Wilson@sae.org" }, # Keith Wilson is Authorized Individual in sae-cell-v2x +# {"person_id": 124277, "group_id": 2212, "email": "Elizabeth.Perry@sae.org" }, # Elizabeth Perry is Authorized Individual in sae-cell-v2x +# {"person_id": 117430, "group_id": 1940, "email": "admin@trustedcomputinggroup.org" }, # Lindsay Adamson is Authorized Individual in tcg +# {"person_id": 117422, "group_id": 1934, "email": "j.hietala@opengroup.org" }, # Jim Hietala is Authorized Individual in the-open-group +# {"person_id": 112104, "group_id": 53, "email": "rick@unicode.org" }, # Rick McGowan is Authorized Individual in unicode +# {"person_id": 112103, "group_id": 54, "email": "plh@w3.org" }, # Philippe Le Hégaret is Authorized Individual in w3c +# {"person_id": 120261, "group_id": 54, "email": "wendy@seltzer.org" }, # Wendy Seltzer is Authorized Individual in w3c +# {"person_id": 118020, "group_id": 1955, "email": "tiago@wballiance.com" }, # Tiago Rodrigues is Authorized Individual in wba +# {"person_id": 125489, "group_id": 1955, "email": "bruno@wballiance.com" }, # Bruno Tomas is Authorized Individual in wba +# {"person_id": 109129, "group_id": 70, "email": "smccammon@amsl.com" }, # Stephanie McCammon is Authorized Individual in zigbee-alliance +# ]} diff --git a/ietf/group/migrations/0006_remove_liason_contacts.py b/ietf/group/migrations/0006_remove_liason_contacts.py new file mode 100644 index 0000000000..13afd1a53e --- /dev/null +++ b/ietf/group/migrations/0006_remove_liason_contacts.py @@ -0,0 +1,270 @@ +# Copyright The IETF Trust 2025, All Rights Reserved + +from collections import defaultdict + +from django.db import migrations + +from ietf.person.name import plain_name + + +def get_plain_name(person): + return person.plain or plain_name(person.name) + + +def forward(apps, schema_editor): + """Removes liaison_contact and liaison_cc_contact roles from all groups + + The IAB has decided to remove the liaison_contact and liaison_cc_contact + role concept from the datatracker as the roles are not well understood + and have not been being maintained. + """ + Role = apps.get_model("group", "Role") + GroupEvent = apps.get_model("group", "GroupEvent") + for role_name in ["liaison_contact", "liaison_cc_contact"]: + groups = defaultdict(list) + role_qs = Role.objects.filter(name_id=role_name) + for role in role_qs: + groups[role.group].append(role) + for group in groups: + desc = f"Removed {role_name}: {', '.join([get_plain_name(role.person) for role in groups[group]])}" + GroupEvent.objects.create( + group=group, + by_id=1, # (System) + desc=desc, + ) + role_qs.delete() + + +def reverse(apps, schema_editor): + """Intentionally does nothing""" + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("group", "0005_remove_sdo_authorized_individuals"), + ] + + operations = [ + migrations.RunPython(forward, reverse), + ] + + +# At the time this migration was created, it would remove these objects +# {"liaison_contacts":[ +# { "role_name": "liaison_contact", "person_id": 107943, "group_id": 56, "email": "3GPPLiaison@etsi.org" }, # Susanna Kooistra is Liaison Contact in 3gpp +# { "role_name": "liaison_contact", "person_id": 107737, "group_id": 56, "email": "lionel.morand@orange.com" }, # Lionel Morand is Liaison Contact in 3gpp +# { "role_name": "liaison_contact", "person_id": 127959, "group_id": 57, "email": "mahendra@qualcomm.com" }, # Mahendran Ac is Liaison Contact in 3gpp2 +# { "role_name": "liaison_contact", "person_id": 111440, "group_id": 2026, "email": "georg.mayer.huawei@gmx.com" }, # Georg Mayer is Liaison Contact in 3gpp-tsgct +# { "role_name": "liaison_contact", "person_id": 107943, "group_id": 2026, "email": "3GPPLiaison@etsi.org" }, # Susanna Kooistra is Liaison Contact in 3gpp-tsgct +# { "role_name": "liaison_contact", "person_id": 107943, "group_id": 2027, "email": "3GPPLiaison@etsi.org" }, # Susanna Kooistra is Liaison Contact in 3gpp-tsgct-ct1 +# { "role_name": "liaison_contact", "person_id": 107737, "group_id": 2027, "email": "lionel.morand@orange.com" }, # Lionel Morand is Liaison Contact in 3gpp-tsgct-ct1 +# { "role_name": "liaison_contact", "person_id": 107737, "group_id": 2410, "email": "lionel.morand@orange.com" }, # Lionel Morand is Liaison Contact in 3gpp-tsgct-ct3 +# { "role_name": "liaison_contact", "person_id": 107943, "group_id": 2410, "email": "3GPPLiaison@etsi.org" }, # Susanna Kooistra is Liaison Contact in 3gpp-tsgct-ct3 +# { "role_name": "liaison_contact", "person_id": 107737, "group_id": 2028, "email": "lionel.morand@orange.com" }, # Lionel Morand is Liaison Contact in 3gpp-tsgct-ct4 +# { "role_name": "liaison_contact", "person_id": 107943, "group_id": 2028, "email": "3GPPLiaison@etsi.org" }, # Susanna Kooistra is Liaison Contact in 3gpp-tsgct-ct4 +# { "role_name": "liaison_contact", "person_id": 107943, "group_id": 2029, "email": "3GPPLiaison@etsi.org" }, # Susanna Kooistra is Liaison Contact in 3gpp-tsgran +# { "role_name": "liaison_contact", "person_id": 111440, "group_id": 2029, "email": "georg.mayer.huawei@gmx.com" }, # Georg Mayer is Liaison Contact in 3gpp-tsgran +# { "role_name": "liaison_contact", "person_id": 107737, "group_id": 2030, "email": "lionel.morand@orange.com" }, # Lionel Morand is Liaison Contact in 3gpp-tsgran-ran2 +# { "role_name": "liaison_contact", "person_id": 107943, "group_id": 2030, "email": "3GPPLiaison@etsi.org" }, # Susanna Kooistra is Liaison Contact in 3gpp-tsgran-ran2 +# { "role_name": "liaison_contact", "person_id": 107943, "group_id": 2023, "email": "3GPPLiaison@etsi.org" }, # Susanna Kooistra is Liaison Contact in 3gpp-tsgsa +# { "role_name": "liaison_contact", "person_id": 111440, "group_id": 2023, "email": "georg.mayer.huawei@gmx.com" }, # Georg Mayer is Liaison Contact in 3gpp-tsgsa +# { "role_name": "liaison_contact", "person_id": 107943, "group_id": 2024, "email": "3GPPLiaison@etsi.org" }, # Susanna Kooistra is Liaison Contact in 3gpp-tsgsa-sa2 +# { "role_name": "liaison_contact", "person_id": 107737, "group_id": 2024, "email": "lionel.morand@orange.com" }, # Lionel Morand is Liaison Contact in 3gpp-tsgsa-sa2 +# { "role_name": "liaison_contact", "person_id": 107943, "group_id": 2025, "email": "3GPPLiaison@etsi.org" }, # Susanna Kooistra is Liaison Contact in 3gpp-tsgsa-sa3 +# { "role_name": "liaison_contact", "person_id": 107737, "group_id": 2025, "email": "lionel.morand@orange.com" }, # Lionel Morand is Liaison Contact in 3gpp-tsgsa-sa3 +# { "role_name": "liaison_contact", "person_id": 107737, "group_id": 1902, "email": "lionel.morand@orange.com" }, # Lionel Morand is Liaison Contact in 3gpp-tsgsa-sa4 +# { "role_name": "liaison_contact", "person_id": 107943, "group_id": 1902, "email": "3GPPLiaison@etsi.org" }, # Susanna Kooistra is Liaison Contact in 3gpp-tsgsa-sa4 +# { "role_name": "liaison_contact", "person_id": 107943, "group_id": 2031, "email": "3GPPLiaison@etsi.org" }, # Susanna Kooistra is Liaison Contact in 3gpp-tsgt-wg2 +# { "role_name": "liaison_contact", "person_id": 107737, "group_id": 2031, "email": "lionel.morand@orange.com" }, # Lionel Morand is Liaison Contact in 3gpp-tsgt-wg2 +# { "role_name": "liaison_contact", "person_id": 106345, "group_id": 1396, "email": "Menachem.Dodge@ecitele.com" }, # Menachem Dodge is Liaison Contact in adslmib +# { "role_name": "liaison_contact", "person_id": 108054, "group_id": 1956, "email": "shengjiang@bupt.edu.cn" }, # Sheng Jiang is Liaison Contact in anima +# { "role_name": "liaison_contact", "person_id": 11834, "group_id": 1956, "email": "tte@cs.fau.de" }, # Toerless Eckert is Liaison Contact in anima +# { "role_name": "liaison_contact", "person_id": 21684, "group_id": 1805, "email": "barryleiba@computer.org" }, # Barry Leiba is Liaison Contact in appsawg +# { "role_name": "liaison_contact", "person_id": 102154, "group_id": 1805, "email": "alexey.melnikov@isode.com" }, # Alexey Melnikov is Liaison Contact in appsawg +# { "role_name": "liaison_contact", "person_id": 107279, "group_id": 1805, "email": "yaojk@cnnic.cn" }, # Jiankang Yao is Liaison Contact in appsawg +# { "role_name": "liaison_contact", "person_id": 100754, "group_id": 941, "email": "tom.taylor@rogers.com" }, # Tom Taylor is Liaison Contact in avt +# { "role_name": "liaison_contact", "person_id": 105873, "group_id": 941, "email": "ron.even.tlv@gmail.com" }, # Roni Even is Liaison Contact in avt +# { "role_name": "liaison_contact", "person_id": 105097, "group_id": 1813, "email": "keith.drage@alcatel-lucent.com" }, # Keith Drage is Liaison Contact in avtext +# { "role_name": "liaison_contact", "person_id": 101923, "group_id": 1813, "email": "jonathan@vidyo.com" }, # Jonathan Lennox is Liaison Contact in avtext +# { "role_name": "liaison_contact", "person_id": 108279, "group_id": 1960, "email": "martin.vigoureux@alcatel-lucent.com" }, # Martin Vigoureux is Liaison Contact in bess +# { "role_name": "liaison_contact", "person_id": 109666, "group_id": 66, "email": "g.white@cablelabs.com" }, # Greg White is Liaison Contact in cablelabs +# { "role_name": "liaison_contact", "person_id": 117421, "group_id": 1933, "email": "chairman@dmtf.org" }, # Winston Bumpus is Liaison Contact in dmtf +# { "role_name": "liaison_contact", "person_id": 127961, "group_id": 1739, "email": "statements@ietf.org" }, # statements@ietf.org is Liaison Contact in drinks +# { "role_name": "liaison_contact", "person_id": 109505, "group_id": 1787, "email": "bernie@ietf.hoeneisen.ch" }, # Bernie Hoeneisen is Liaison Contact in e2md +# { "role_name": "liaison_contact", "person_id": 109059, "group_id": 1787, "email": "ray.bellis@nominet.org.uk" }, # Ray Bellis is Liaison Contact in e2md +# { "role_name": "liaison_contact", "person_id": 116529, "group_id": 1919, "email": "istvan@ecma-interational.org" }, # Istvan Sebestyen is Liaison Contact in ecma-tc39 +# { "role_name": "liaison_contact", "person_id": 127964, "group_id": 1919, "email": "johnneumann.openstrat@gmail.com" }, # John Neuman is Liaison Contact in ecma-tc39 +# { "role_name": "liaison_contact", "person_id": 106012, "group_id": 1643, "email": "marc.linsner@cisco.com" }, # Marc Linsner is Liaison Contact in ecrit +# { "role_name": "liaison_contact", "person_id": 107084, "group_id": 1643, "email": "rmarshall@telecomsys.com" }, # Roger Marshall is Liaison Contact in ecrit +# { "role_name": "liaison_contact", "person_id": 116363, "group_id": 1915, "email": "e2nasupport@etsi.org" }, # Sonia Compans is Liaison Contact in etsi-e2na +# { "role_name": "liaison_contact", "person_id": 126473, "group_id": 2261, "email": "isgsupport@etsi.org" }, # Sonia Compan is Liaison Contact in etsi-isg-sai +# { "role_name": "liaison_contact", "person_id": 128316, "group_id": 2301, "email": "GSMALiaisons@gsma.com" }, # David Pollington is Liaison Contact in gsma-ztc +# { "role_name": "liaison_contact", "person_id": 3056, "group_id": 1875, "email": "shares@ndzh.com" }, # Susan Hares is Liaison Contact in i2rs +# { "role_name": "liaison_contact", "person_id": 105046, "group_id": 1875, "email": "jhaas@pfrc.org" }, # Jeffrey Haas is Liaison Contact in i2rs +# { "role_name": "liaison_contact", "person_id": 120845, "group_id": 61, "email": "tale@dd.org" }, # David Lawrence is Liaison Contact in icann-board-of-directors +# { "role_name": "liaison_contact", "person_id": 112851, "group_id": 2105, "email": "pthaler@broadcom.com" }, # Patricia Thaler is Liaison Contact in ieee-802 +# { "role_name": "liaison_contact", "person_id": 127968, "group_id": 2105, "email": "p.nikolich@ieee.org" }, # Paul Nikolich is Liaison Contact in ieee-802 +# { "role_name": "liaison_contact", "person_id": 19651, "group_id": 63, "email": "glenn.parsons@ericsson.com" }, # Glenn Parsons is Liaison Contact in ieee-802-1 +# { "role_name": "liaison_contact", "person_id": 123875, "group_id": 63, "email": "JMessenger@advaoptical.com" }, # John Messenger is Liaison Contact in ieee-802-1 +# { "role_name": "liaison_contact", "person_id": 127968, "group_id": 63, "email": "p.nikolich@ieee.org" }, # Paul Nikolich is Liaison Contact in ieee-802-1 +# { "role_name": "liaison_contact", "person_id": 117415, "group_id": 1862, "email": "Adrian.P.Stephens@intel.com" }, # Adrian Stephens is Liaison Contact in ieee-802-11 +# { "role_name": "liaison_contact", "person_id": 106284, "group_id": 1862, "email": "dstanley@agere.com" }, # Dorothy Stanley is Liaison Contact in ieee-802-11 +# { "role_name": "liaison_contact", "person_id": 128345, "group_id": 2302, "email": "liaison@iowngf.org" }, # Forum Iown is Liaison Contact in iown-global-forum +# { "role_name": "liaison_contact", "person_id": 117428, "group_id": 1939, "email": "walter.fumy@bdr.de" }, # Walter Fumy is Liaison Contact in iso-iec-jtc1-sc27 +# { "role_name": "liaison_contact", "person_id": 117429, "group_id": 1939, "email": "krystyna.passia@din.de" }, # Krystyna Passia is Liaison Contact in iso-iec-jtc1-sc27 +# { "role_name": "liaison_contact", "person_id": 151289, "group_id": 50, "email": "koike@itscj.ipsj.or.jp" }, # Mayumi Koike is Liaison Contact in iso-iec-jtc1-sc29 +# { "role_name": "liaison_contact", "person_id": 151289, "group_id": 2110, "email": "koike@itscj.ipsj.or.jp" }, # Mayumi Koike is Liaison Contact in iso-iec-jtc1-sc29-wg1 +# { "role_name": "liaison_contact", "person_id": 114435, "group_id": 74, "email": "watanabe@itscj.ipsj.or.jp" }, # Shinji Watanabe is Liaison Contact in iso-iec-jtc1-sc29-wg11 +# { "role_name": "liaison_contact", "person_id": 112106, "group_id": 49, "email": "jooran@kisi.or.kr" }, # Jooran Lee is Liaison Contact in iso-iec-jtc1-sc6 +# { "role_name": "liaison_contact", "person_id": 113587, "group_id": 49, "email": "dykim@cnu.kr" }, # Chungnam University is Liaison Contact in iso-iec-jtc1-sc6 +# { "role_name": "liaison_contact", "person_id": 117427, "group_id": 1938, "email": "secretariat@jtc1-sc7.org" }, # Witold Suryn is Liaison Contact in iso-iec-jtc1-sc7 +# { "role_name": "liaison_contact", "person_id": 117426, "group_id": 1938, "email": "chair@jtc1-sc7.org" }, # Francois Coallier is Liaison Contact in iso-iec-jtc1-sc7 +# { "role_name": "liaison_contact", "person_id": 127971, "group_id": 68, "email": "sabine.donnardcusse@afnor.org" }, # sabine.donnardcusse@afnor.org is Liaison Contact in isotc46 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2057, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 1890, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-r +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2058, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-r-wp5a +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2059, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-r-wp5d +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2060, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-r-wp8a +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2061, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-r-wp8f +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 51, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2063, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-fg-cloud +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 1860, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-fg-dist +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2064, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-fg-iptv +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2065, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-fg-ngnm +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2062, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-ipv6-group +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 1872, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-jca-cloud +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 1874, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-jca-cop +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2066, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-jca-idm +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 1927, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-jca-sdn +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 65, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-mpls +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 52, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-ngn +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2067, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-ngnmfg +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 77, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-11 +# { "role_name": "liaison_contact", "person_id": 128236, "group_id": 77, "email": "denis.andreev@itu.int" }, # Denis ANDREEV is Liaison Contact in itu-t-sg-11 +# { "role_name": "liaison_contact", "person_id": 107300, "group_id": 77, "email": "tatiana.kurakova@itu.int" }, # Tatiana Kurakova is Liaison Contact in itu-t-sg-11 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2074, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-11-q5 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2075, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-11-wp2 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 84, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-12 +# { "role_name": "liaison_contact", "person_id": 102900, "group_id": 84, "email": "acmorton@att.com" }, # Al Morton is Liaison Contact in itu-t-sg-12 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2076, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-12-q12 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2077, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-12-q17 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2082, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-13-q11 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2078, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-13-q3 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2079, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-13-q5 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2080, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-13-q7 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2081, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-13-q9 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2083, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-13-wp3 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2084, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-13-wp4 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2085, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-13-wp5 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2086, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-14 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 62, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-15 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2087, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-15-q1 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2092, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-15-q10 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2093, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-15-q11 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2094, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-15-q12 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2095, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-15-q14 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2096, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-15-q15 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2088, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-15-q3 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2089, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-15-q4 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2090, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-15-q6 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2091, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-15-q9 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2097, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-15-wp1 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2098, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-15-wp3 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 72, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-16 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2101, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-16-q10 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 1987, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-16-q3 +# { "role_name": "liaison_contact", "person_id": 118539, "group_id": 1987, "email": "rosa.angelesleondev@itu.int" }, # Rosa De Vivero is Liaison Contact in itu-t-sg-16-q3 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2099, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-16-q8 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2100, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-16-q9 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 76, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-17 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2102, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-17-q2 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 1937, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-17-q4 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 1954, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-17-tsb +# { "role_name": "liaison_contact", "person_id": 12898, "group_id": 1954, "email": "youki-k@is.aist-nara.ac.jp" }, # Youki Kadobayashi is Liaison Contact in itu-t-sg-17-tsb +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 78, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-2 +# { "role_name": "liaison_contact", "person_id": 127962, "group_id": 78, "email": "dr.guinena@ntra.gov.eg" }, # dr.guinena@ntra.gov.eg is Liaison Contact in itu-t-sg-2 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2103, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-20 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2073, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-2-q1 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 79, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-3 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2068, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-4 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2000, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-5 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2069, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-6 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2070, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-7 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2071, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-8 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 2072, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-sg-9 +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 82, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-tsag +# { "role_name": "liaison_contact", "person_id": 127957, "group_id": 82, "email": "tsbtsag@itu.int" }, # Bilel Jamoussi is Liaison Contact in itu-t-tsag +# { "role_name": "liaison_cc_contact", "person_id": 127958, "group_id": 1846, "email": "itu-t-liaison@iab.org" }, # itu-t liaison is Liaison CC Contact in itu-t-wp-5-13 +# { "role_name": "liaison_contact", "person_id": 10083, "group_id": 1882, "email": "paul.hoffman@vpnc.org" }, # Paul Hoffman is Liaison Contact in json +# { "role_name": "liaison_contact", "person_id": 111178, "group_id": 1882, "email": "mamille2@cisco.com" }, # Matthew Miller is Liaison Contact in json +# { "role_name": "liaison_contact", "person_id": 106881, "group_id": 1593, "email": "vach.kompella@alcatel.com" }, # Vach Kompella is Liaison Contact in l2vpn +# { "role_name": "liaison_contact", "person_id": 19987, "group_id": 1593, "email": "danny@arbor.net" }, # Danny McPherson is Liaison Contact in l2vpn +# { "role_name": "liaison_contact", "person_id": 2329, "group_id": 1593, "email": "stbryant@cisco.com" }, # Stewart Bryant is Liaison Contact in l2vpn +# { "role_name": "liaison_contact", "person_id": 101552, "group_id": 1593, "email": "Shane.Amante@Level3.com" }, # Shane Amante is Liaison Contact in l2vpn +# { "role_name": "liaison_contact", "person_id": 110305, "group_id": 1877, "email": "jason.weil@twcable.com" }, # Jason Weil is Liaison Contact in lmap +# { "role_name": "liaison_contact", "person_id": 6699, "group_id": 1877, "email": "dromasca@avaya.com" }, # Dan Romascanu is Liaison Contact in lmap +# { "role_name": "liaison_contact", "person_id": 127969, "group_id": 69, "email": "madkins@fb.com" }, # Mike Adkins is Liaison Contact in maawg +# { "role_name": "liaison_contact", "person_id": 127970, "group_id": 69, "email": "technical-chair@mailman.m3aawg.org" }, # technical-chair@mailman.m3aawg.org is Liaison Contact in maawg +# { "role_name": "liaison_contact", "person_id": 112512, "group_id": 75, "email": "rraghu@ciena.com" }, # Raghu Ranganathan is Liaison Contact in mef +# { "role_name": "liaison_contact", "person_id": 119947, "group_id": 1755, "email": "mrw@lilacglade.org" }, # Margaret Cullen is Liaison Contact in mif +# { "role_name": "liaison_contact", "person_id": 109884, "group_id": 1755, "email": "denghui02@hotmail.com" }, # Hui Deng is Liaison Contact in mif +# { "role_name": "liaison_contact", "person_id": 128292, "group_id": 1936, "email": "james.olthoff@nist.gov" }, # James Olthoff is Liaison Contact in nist +# { "role_name": "liaison_contact", "person_id": 104183, "group_id": 1537, "email": "john.loughney@nokia.com" }, # John Loughney is Liaison Contact in nsis +# { "role_name": "liaison_contact", "person_id": 105786, "group_id": 1840, "email": "matthew.bocci@nokia.com" }, # Matthew Bocci is Liaison Contact in nvo3 +# { "role_name": "liaison_contact", "person_id": 112438, "group_id": 1840, "email": "bensons@queuefull.net" }, # Benson Schliesser is Liaison Contact in nvo3 +# { "role_name": "liaison_contact", "person_id": 107943, "group_id": 2296, "email": "3GPPLiaison@etsi.org" }, # Susanna Kooistra is Liaison Contact in o3gpptsgran3 +# { "role_name": "liaison_contact", "person_id": 127966, "group_id": 1941, "email": "chet.ensign@oasis-open.org" }, # chet.ensign@oasis-open.org is Liaison Contact in oasis +# { "role_name": "liaison_contact", "person_id": 117423, "group_id": 1935, "email": "soley@omg.org" }, # Richard Soley is Liaison Contact in omg +# { "role_name": "liaison_contact", "person_id": 127963, "group_id": 1858, "email": "dan.pitt@opennetworkingfoundation.org" }, # dan.pitt@opennetworkingfoundation.org is Liaison Contact in onf +# { "role_name": "liaison_contact", "person_id": 108304, "group_id": 1599, "email": "gunter.van_de_velde@nokia.com" }, # Gunter Van de Velde is Liaison Contact in opsec +# { "role_name": "liaison_contact", "person_id": 111647, "group_id": 1599, "email": "kk@google.com" }, # Chittimaneni Kk is Liaison Contact in opsec +# { "role_name": "liaison_contact", "person_id": 111656, "group_id": 1599, "email": "warren@kumari.net" }, # Warren Kumari is Liaison Contact in opsec +# { "role_name": "liaison_contact", "person_id": 106471, "group_id": 1188, "email": "dbrungard@att.com" }, # Deborah Brungard is Liaison Contact in ospf +# { "role_name": "liaison_contact", "person_id": 104198, "group_id": 1188, "email": "adrian@olddog.co.uk" }, # Adrian Farrel is Liaison Contact in ospf +# { "role_name": "liaison_contact", "person_id": 104816, "group_id": 1188, "email": "akr@cisco.com" }, # Abhay Roy is Liaison Contact in ospf +# { "role_name": "liaison_contact", "person_id": 10784, "group_id": 1188, "email": "acee@redback.com" }, # Acee Lindem is Liaison Contact in ospf +# { "role_name": "liaison_contact", "person_id": 108123, "group_id": 1819, "email": "Gabor.Bajko@nokia.com" }, # Gabor Bajko is Liaison Contact in paws +# { "role_name": "liaison_contact", "person_id": 106987, "group_id": 1819, "email": "br@brianrosen.net" }, # Brian Rosen is Liaison Contact in paws +# { "role_name": "liaison_cc_contact", "person_id": 122823, "group_id": 1630, "email": "ketant.ietf@gmail.com" }, # Ketan Talaulikar is Liaison CC Contact in pce +# { "role_name": "liaison_contact", "person_id": 125031, "group_id": 1630, "email": "andrew.stone@nokia.com" }, # Andrew Stone is Liaison Contact in pce +# { "role_name": "liaison_contact", "person_id": 108213, "group_id": 1630, "email": "julien.meuric@orange.com" }, # Julien Meuric is Liaison Contact in pce +# { "role_name": "liaison_contact", "person_id": 111477, "group_id": 1630, "email": "dd@dhruvdhody.com" }, # Dhruv Dhody is Liaison Contact in pce +# { "role_name": "liaison_contact", "person_id": 112773, "group_id": 1701, "email": "lars.eggert@nokia.com" }, # Lars Eggert is Liaison Contact in pcn +# { "role_name": "liaison_contact", "person_id": 12671, "group_id": 1437, "email": "adamson@itd.nrl.navy.mil" }, # Brian Adamson is Liaison Contact in rmt +# { "role_name": "liaison_contact", "person_id": 100609, "group_id": 1437, "email": "lorenzo@vicisano.net" }, # Lorenzo Vicisano is Liaison Contact in rmt +# { "role_name": "liaison_contact", "person_id": 115213, "group_id": 1730, "email": "maria.ines.robles@ericsson.com" }, # Ines Robles is Liaison Contact in roll +# { "role_name": "liaison_contact", "person_id": 110721, "group_id": 1820, "email": "ted.ietf@gmail.com" }, # Ted Hardie is Liaison Contact in rtcweb +# { "role_name": "liaison_contact", "person_id": 104294, "group_id": 1820, "email": "magnus.westerlund@ericsson.com" }, # Magnus Westerlund is Liaison Contact in rtcweb +# { "role_name": "liaison_contact", "person_id": 105791, "group_id": 1820, "email": "fluffy@iii.ca" }, # Cullen Jennings is Liaison Contact in rtcweb +# { "role_name": "liaison_contact", "person_id": 105906, "group_id": 1910, "email": "james.n.guichard@futurewei.com" }, # Jim Guichard is Liaison Contact in sfc +# { "role_name": "liaison_contact", "person_id": 3862, "group_id": 1910, "email": "jmh@joelhalpern.com" }, # Joel Halpern is Liaison Contact in sfc +# { "role_name": "liaison_contact", "person_id": 127960, "group_id": 1462, "email": "sipcore@ietf.org" }, # sipcore@ietf.org is Liaison Contact in sip +# { "role_name": "liaison_contact", "person_id": 103769, "group_id": 1762, "email": "adam@nostrum.com" }, # Adam Roach is Liaison Contact in sipcore +# { "role_name": "liaison_contact", "person_id": 108554, "group_id": 1762, "email": "pkyzivat@alum.mit.edu" }, # Paul Kyzivat is Liaison Contact in sipcore +# { "role_name": "liaison_contact", "person_id": 103539, "group_id": 1542, "email": "gonzalo.camarillo@ericsson.com" }, # Gonzalo Camarillo is Liaison Contact in sipping +# { "role_name": "liaison_contact", "person_id": 103612, "group_id": 1542, "email": "jf.mule@cablelabs.com" }, # Jean-Francois Mule is Liaison Contact in sipping +# { "role_name": "liaison_contact", "person_id": 3862, "group_id": 1905, "email": "jmh@joelhalpern.com" }, # Joel Halpern is Liaison Contact in spring +# { "role_name": "liaison_contact", "person_id": 109802, "group_id": 1905, "email": "aretana.ietf@gmail.com" }, # Alvaro Retana is Liaison Contact in spring +# { "role_name": "liaison_contact", "person_id": 107172, "group_id": 1905, "email": "bruno.decraene@orange.com" }, # Bruno Decraene is Liaison Contact in spring +# { "role_name": "liaison_contact", "person_id": 5376, "group_id": 1899, "email": "housley@vigilsec.com" }, # Russ Housley is Liaison Contact in stir +# { "role_name": "liaison_contact", "person_id": 103961, "group_id": 1899, "email": "rjsparks@nostrum.com" }, # Robert Sparks is Liaison Contact in stir +# { "role_name": "liaison_contact", "person_id": 117430, "group_id": 1940, "email": "admin@trustedcomputinggroup.org" }, # Lindsay Adamson is Liaison Contact in tcg +# { "role_name": "liaison_contact", "person_id": 110932, "group_id": 1985, "email": "oscar.gonzalezdedios@telefonica.com" }, # Oscar de Dios is Liaison Contact in teas +# { "role_name": "liaison_contact", "person_id": 10064, "group_id": 1985, "email": "lberger@labn.net" }, # Lou Berger is Liaison Contact in teas +# { "role_name": "liaison_contact", "person_id": 114351, "group_id": 1985, "email": "vbeeram@juniper.net" }, # Vishnu Beeram is Liaison Contact in teas +# { "role_name": "liaison_contact", "person_id": 117422, "group_id": 1934, "email": "j.hietala@opengroup.org" }, # Jim Hietala is Liaison Contact in the-open-group +# { "role_name": "liaison_contact", "person_id": 106414, "group_id": 1709, "email": "yaakovjstein@gmail.com" }, # Yaakov Stein is Liaison Contact in tictoc +# { "role_name": "liaison_contact", "person_id": 4857, "group_id": 1709, "email": "kodonog@pobox.com" }, # Karen O'Donoghue is Liaison Contact in tictoc +# { "role_name": "liaison_contact", "person_id": 144713, "group_id": 2420, "email": "liaisons@tmforum.org" }, # liaisons@tmforum.org is Liaison Contact in tmforum +# { "role_name": "liaison_contact", "person_id": 112773, "group_id": 1324, "email": "lars@eggert.org" }, # Lars Eggert is Liaison Contact in tsv +# { "role_name": "liaison_contact", "person_id": 112104, "group_id": 53, "email": "rick@unicode.org" }, # Rick McGowan is Liaison Contact in unicode +# { "role_name": "liaison_contact", "person_id": 105907, "group_id": 1864, "email": "stpeter@stpeter.im" }, # Peter Saint-Andre is Liaison Contact in videocodec +# { "role_name": "liaison_contact", "person_id": 120261, "group_id": 54, "email": "wseltzer@w3.org" }, # Wendy Seltzer is Liaison Contact in w3c +# { "role_name": "liaison_contact", "person_id": 112103, "group_id": 54, "email": "plh@w3.org" }, # Philippe Le Hégaret is Liaison Contact in w3c +# { "role_name": "liaison_contact", "person_id": 107520, "group_id": 1957, "email": "shida@ntt-at.com" }, # Shida Schubert is Liaison Contact in webpush +# { "role_name": "liaison_contact", "person_id": 110049, "group_id": 1957, "email": "jhildebr@cisco.com" }, # Joe Hildebrand is Liaison Contact in webpush +# { "role_name": "liaison_contact", "person_id": 103769, "group_id": 1601, "email": "adam@nostrum.com" }, # Adam Roach is Liaison Contact in xcon +# { "role_name": "liaison_contact", "person_id": 107520, "group_id": 1815, "email": "shida@ntt-at.com" }, # Shida Schubert is Liaison Contact in xrblock +# { "role_name": "liaison_contact", "person_id": 6699, "group_id": 1815, "email": "dromasca@avaya.com" }, # Dan Romascanu is Liaison Contact in xrblock +# ]} diff --git a/ietf/group/migrations/0007_used_roles.py b/ietf/group/migrations/0007_used_roles.py new file mode 100644 index 0000000000..0dfa79fa03 --- /dev/null +++ b/ietf/group/migrations/0007_used_roles.py @@ -0,0 +1,49 @@ +# Copyright The IETF Trust 2025, All Rights Reserved + +from django.db import migrations + + +def forward(apps, schema_editor): + Group = apps.get_model("group", "Group") + GroupFeatures = apps.get_model("group", "GroupFeatures") + iab = Group.objects.get(acronym="iab") + iab.used_roles = [ + "chair", + "delegate", + "exofficio", + "liaison", + "liaison_coordinator", + "member", + ] + iab.save() + GroupFeatures.objects.filter(type_id="ietf").update( + default_used_roles=[ + "ad", + "member", + "comdir", + "delegate", + "execdir", + "recman", + "secr", + "chair", + ] + ) + + +def reverse(apps, schema_editor): + Group = apps.get_model("group", "Group") + iab = Group.objects.get(acronym="iab") + iab.used_roles = [] + iab.save() + # Intentionally not putting trac-* back into grouptype ietf default_used_roles + + +class Migration(migrations.Migration): + dependencies = [ + ("group", "0006_remove_liason_contacts"), + ("name", "0018_alter_rolenames"), + ] + + operations = [ + migrations.RunPython(forward, reverse), + ] diff --git a/ietf/ietfauth/forms.py b/ietf/ietfauth/forms.py index a70f7b6ca1..53bf7b5888 100644 --- a/ietf/ietfauth/forms.py +++ b/ietf/ietfauth/forms.py @@ -3,16 +3,19 @@ import re + from unidecode import unidecode from django import forms +from django.contrib.auth.models import User +from django.contrib.auth import password_validation from django.core.exceptions import ValidationError from django.db import models -from django.contrib.auth.models import User from ietf.person.models import Person, Email from ietf.mailinglists.models import Allowlisted from ietf.utils.text import isascii +from .password_validation import StrongPasswordValidator from .validators import prevent_at_symbol, prevent_system_name, prevent_anonymous_name, is_allowed_address from .widgets import PasswordStrengthInput, PasswordConfirmationInput @@ -170,33 +173,52 @@ class Meta: model = Allowlisted exclude = ['by', 'time' ] - -from django import forms - class ChangePasswordForm(forms.Form): current_password = forms.CharField(widget=forms.PasswordInput) - new_password = forms.CharField(widget=PasswordStrengthInput(attrs={'class':'password_strength'})) - new_password_confirmation = forms.CharField(widget=PasswordConfirmationInput( - confirm_with='new_password', - attrs={'class':'password_confirmation'})) + new_password = forms.CharField( + widget=PasswordStrengthInput( + attrs={ + "class": "password_strength", + "data-disable-strength-enforcement": "", # usually removed in init + } + ), + ) + new_password_confirmation = forms.CharField( + widget=PasswordConfirmationInput( + confirm_with="new_password", attrs={"class": "password_confirmation"} + ) + ) def __init__(self, user, data=None): self.user = user - super(ChangePasswordForm, self).__init__(data) + super().__init__(data) + # Check whether we have validators to enforce + new_password_field = self.fields["new_password"] + for pwval in password_validation.get_default_password_validators(): + if isinstance(pwval, password_validation.MinimumLengthValidator): + new_password_field.widget.attrs["minlength"] = pwval.min_length + elif isinstance(pwval, StrongPasswordValidator): + new_password_field.widget.attrs.pop( + "data-disable-strength-enforcement", None + ) def clean_current_password(self): - password = self.cleaned_data.get('current_password', None) + # n.b., password = None is handled by check_password and results in a failed check + password = self.cleaned_data.get("current_password", None) if not self.user.check_password(password): - raise ValidationError('Invalid password') + raise ValidationError("Invalid password") return password - + def clean(self): - new_password = self.cleaned_data.get('new_password', None) - conf_password = self.cleaned_data.get('new_password_confirmation', None) - if not new_password == conf_password: - raise ValidationError("The password confirmation is different than the new password") + new_password = self.cleaned_data.get("new_password", "") + conf_password = self.cleaned_data.get("new_password_confirmation", "") + if new_password != conf_password: + raise ValidationError( + "The password confirmation is different than the new password" + ) + password_validation.validate_password(conf_password, self.user) class ChangeUsernameForm(forms.Form): diff --git a/ietf/ietfauth/password_validation.py b/ietf/ietfauth/password_validation.py new file mode 100644 index 0000000000..bfed4a784e --- /dev/null +++ b/ietf/ietfauth/password_validation.py @@ -0,0 +1,23 @@ +# Copyright The IETF Trust 2025, All Rights Reserved +from django.core.exceptions import ValidationError +from zxcvbn import zxcvbn + + +class StrongPasswordValidator: + message = "This password does not meet complexity requirements and is easily guessable." + code = "weak" + min_zxcvbn_score = 3 + + def __init__(self, message=None, code=None, min_zxcvbn_score=None): + if message is not None: + self.message = message + if code is not None: + self.code = code + if min_zxcvbn_score is not None: + self.min_zxcvbn_score = min_zxcvbn_score + + def validate(self, password, user=None): + """Validate that a password is strong enough""" + strength_report = zxcvbn(password[:72], max_length=72) + if strength_report["score"] < self.min_zxcvbn_score: + raise ValidationError(message=self.message, code=self.code) diff --git a/ietf/ietfauth/tests.py b/ietf/ietfauth/tests.py index f6d7671bc9..dd23277b63 100644 --- a/ietf/ietfauth/tests.py +++ b/ietf/ietfauth/tests.py @@ -392,14 +392,14 @@ def test_nomcom_dressing_on_profile(self): self.assertFalse(q('#volunteer-button')) self.assertTrue(q('#volunteered')) - def test_reset_password(self): + VALID_PASSWORD = "complex-and-long-valid-password" + ANOTHER_VALID_PASSWORD = "very-complicated-and-lengthy-password" url = urlreverse("ietf.ietfauth.views.password_reset") - email = 'someone@example.com' - password = 'foobar' + email = "someone@example.com" user = PersonFactory(user__email=email).user - user.set_password(password) + user.set_password(VALID_PASSWORD) user.save() # get @@ -407,21 +407,23 @@ def test_reset_password(self): self.assertEqual(r.status_code, 200) # ask for reset, wrong username (form should not fail) - r = self.client.post(url, { 'username': "nobody@example.com" }) + r = self.client.post(url, {"username": "nobody@example.com"}) self.assertEqual(r.status_code, 200) q = PyQuery(r.content) self.assertTrue(len(q("form .is-invalid")) == 0) # ask for reset empty_outbox() - r = self.client.post(url, { 'username': user.username }) + r = self.client.post(url, {"username": user.username}) self.assertEqual(r.status_code, 200) self.assertEqual(len(outbox), 1) # goto change password page, logged in as someone else confirm_url = self.extract_confirm_url(outbox[-1]) other_user = UserFactory() - self.client.login(username=other_user.username, password=other_user.username + '+password') + self.client.login( + username=other_user.username, password=other_user.username + "+password" + ) r = self.client.get(confirm_url) self.assertEqual(r.status_code, 403) @@ -430,17 +432,32 @@ def test_reset_password(self): r = self.client.get(confirm_url) self.assertEqual(r.status_code, 200) q = PyQuery(r.content) - self.assertNotIn(user.username, q('.nav').text(), - 'user should not appear signed in while resetting password') + self.assertNotIn( + user.username, + q(".nav").text(), + "user should not appear signed in while resetting password", + ) # password mismatch - r = self.client.post(confirm_url, { 'password': 'secret', 'password_confirmation': 'nosecret' }) + r = self.client.post( + confirm_url, + { + "password": ANOTHER_VALID_PASSWORD, + "password_confirmation": ANOTHER_VALID_PASSWORD[::-1], + }, + ) self.assertEqual(r.status_code, 200) q = PyQuery(r.content) self.assertTrue(len(q("form .is-invalid")) > 0) # confirm - r = self.client.post(confirm_url, { 'password': 'secret', 'password_confirmation': 'secret' }) + r = self.client.post( + confirm_url, + { + "password": ANOTHER_VALID_PASSWORD, + "password_confirmation": ANOTHER_VALID_PASSWORD, + }, + ) self.assertEqual(r.status_code, 200) q = PyQuery(r.content) self.assertEqual(len(q("form .is-invalid")), 0) @@ -451,15 +468,18 @@ def test_reset_password(self): # login after reset request empty_outbox() - user.set_password(password) + user.set_password(VALID_PASSWORD) user.save() - r = self.client.post(url, { 'username': user.username }) + r = self.client.post(url, {"username": user.username}) self.assertEqual(r.status_code, 200) self.assertEqual(len(outbox), 1) confirm_url = self.extract_confirm_url(outbox[-1]) - r = self.client.post(urlreverse("ietf.ietfauth.views.login"), {'username': email, 'password': password}) + r = self.client.post( + urlreverse("ietf.ietfauth.views.login"), + {"username": email, "password": VALID_PASSWORD}, + ) r = self.client.get(confirm_url) self.assertEqual(r.status_code, 404) @@ -467,12 +487,12 @@ def test_reset_password(self): # change password after reset request empty_outbox() - r = self.client.post(url, { 'username': user.username }) + r = self.client.post(url, {"username": user.username}) self.assertEqual(r.status_code, 200) self.assertEqual(len(outbox), 1) confirm_url = self.extract_confirm_url(outbox[-1]) - user.set_password('newpassword') + user.set_password(ANOTHER_VALID_PASSWORD) user.save() r = self.client.get(confirm_url) @@ -586,98 +606,175 @@ def test_review_overview(self): self.assertEqual(ReviewWish.objects.filter(doc=doc, team=review_req.team).count(), 0) def test_change_password(self): + VALID_PASSWORD = "complex-and-long-valid-password" + ANOTHER_VALID_PASSWORD = "very-complicated-and-lengthy-password" chpw_url = urlreverse("ietf.ietfauth.views.change_password") prof_url = urlreverse("ietf.ietfauth.views.profile") login_url = urlreverse("ietf.ietfauth.views.login") - redir_url = '%s?next=%s' % (login_url, chpw_url) + redir_url = "%s?next=%s" % (login_url, chpw_url) # get without logging in r = self.client.get(chpw_url) self.assertRedirects(r, redir_url) - user = User.objects.create(username="someone@example.com", email="someone@example.com") - user.set_password("password") + user = User.objects.create( + username="someone@example.com", email="someone@example.com" + ) + user.set_password(VALID_PASSWORD) user.save() p = Person.objects.create(name="Some One", ascii="Some One", user=user) Email.objects.create(address=user.username, person=p, origin=user.username) # log in - r = self.client.post(redir_url, {"username":user.username, "password":"password"}) + r = self.client.post( + redir_url, {"username": user.username, "password": VALID_PASSWORD} + ) self.assertRedirects(r, chpw_url) # wrong current password - r = self.client.post(chpw_url, {"current_password": "fiddlesticks", - "new_password": "foobar", - "new_password_confirmation": "foobar", - }) + r = self.client.post( + chpw_url, + { + "current_password": "fiddlesticks", + "new_password": ANOTHER_VALID_PASSWORD, + "new_password_confirmation": ANOTHER_VALID_PASSWORD, + }, + ) self.assertEqual(r.status_code, 200) - self.assertFormError(r.context["form"], 'current_password', 'Invalid password') + self.assertFormError(r.context["form"], "current_password", "Invalid password") # mismatching new passwords - r = self.client.post(chpw_url, {"current_password": "password", - "new_password": "foobar", - "new_password_confirmation": "barfoo", - }) + r = self.client.post( + chpw_url, + { + "current_password": VALID_PASSWORD, + "new_password": ANOTHER_VALID_PASSWORD, + "new_password_confirmation": ANOTHER_VALID_PASSWORD[::-1], + }, + ) + self.assertEqual(r.status_code, 200) + self.assertFormError( + r.context["form"], + None, + "The password confirmation is different than the new password", + ) + + # password too short + r = self.client.post( + chpw_url, + { + "current_password": VALID_PASSWORD, + "new_password": "sh0rtpw0rd", + "new_password_confirmation": "sh0rtpw0rd", + } + ) self.assertEqual(r.status_code, 200) - self.assertFormError(r.context["form"], None, "The password confirmation is different than the new password") + self.assertFormError( + r.context["form"], + None, + "This password is too short. It must contain at least " + f"{settings.PASSWORD_POLICY_MIN_LENGTH} characters." + ) + + # password too simple + r = self.client.post( + chpw_url, + { + "current_password": VALID_PASSWORD, + "new_password": "passwordpassword", + "new_password_confirmation": "passwordpassword", + } + ) + self.assertEqual(r.status_code, 200) + self.assertFormError( + r.context["form"], + None, + "This password does not meet complexity requirements " + "and is easily guessable." + ) # correct password change - r = self.client.post(chpw_url, {"current_password": "password", - "new_password": "foobar", - "new_password_confirmation": "foobar", - }) + r = self.client.post( + chpw_url, + { + "current_password": VALID_PASSWORD, + "new_password": ANOTHER_VALID_PASSWORD, + "new_password_confirmation": ANOTHER_VALID_PASSWORD, + }, + ) self.assertRedirects(r, prof_url) # refresh user object user = User.objects.get(username="someone@example.com") - self.assertTrue(user.check_password('foobar')) + self.assertTrue(user.check_password(ANOTHER_VALID_PASSWORD)) def test_change_username(self): - + VALID_PASSWORD = "complex-and-long-valid-password" chun_url = urlreverse("ietf.ietfauth.views.change_username") prof_url = urlreverse("ietf.ietfauth.views.profile") login_url = urlreverse("ietf.ietfauth.views.login") - redir_url = '%s?next=%s' % (login_url, chun_url) + redir_url = "%s?next=%s" % (login_url, chun_url) # get without logging in r = self.client.get(chun_url) self.assertRedirects(r, redir_url) - user = User.objects.create(username="someone@example.com", email="someone@example.com") - user.set_password("password") + user = User.objects.create( + username="someone@example.com", email="someone@example.com" + ) + user.set_password(VALID_PASSWORD) user.save() p = Person.objects.create(name="Some One", ascii="Some One", user=user) Email.objects.create(address=user.username, person=p, origin=user.username) - Email.objects.create(address="othername@example.org", person=p, origin=user.username) + Email.objects.create( + address="othername@example.org", person=p, origin=user.username + ) # log in - r = self.client.post(redir_url, {"username":user.username, "password":"password"}) + r = self.client.post( + redir_url, {"username": user.username, "password": VALID_PASSWORD} + ) self.assertRedirects(r, chun_url) # wrong username - r = self.client.post(chun_url, {"username": "fiddlesticks", - "password": "password", - }) + r = self.client.post( + chun_url, + { + "username": "fiddlesticks", + "password": VALID_PASSWORD, + }, + ) self.assertEqual(r.status_code, 200) - self.assertFormError(r.context["form"], 'username', - "Select a valid choice. fiddlesticks is not one of the available choices.") + self.assertFormError( + r.context["form"], + "username", + "Select a valid choice. fiddlesticks is not one of the available choices.", + ) # wrong password - r = self.client.post(chun_url, {"username": "othername@example.org", - "password": "foobar", - }) + r = self.client.post( + chun_url, + { + "username": "othername@example.org", + "password": "foobar", + }, + ) self.assertEqual(r.status_code, 200) - self.assertFormError(r.context["form"], 'password', 'Invalid password') + self.assertFormError(r.context["form"], "password", "Invalid password") # correct username change - r = self.client.post(chun_url, {"username": "othername@example.org", - "password": "password", - }) + r = self.client.post( + chun_url, + { + "username": "othername@example.org", + "password": VALID_PASSWORD, + }, + ) self.assertRedirects(r, prof_url) # refresh user object prev = user user = User.objects.get(username="othername@example.org") self.assertEqual(prev, user) - self.assertTrue(user.check_password('password')) + self.assertTrue(user.check_password(VALID_PASSWORD)) def test_apikey_management(self): # Create a person with a role that will give at least one valid apikey diff --git a/ietf/ietfauth/utils.py b/ietf/ietfauth/utils.py index efdd6f3ea6..e2893a90f7 100644 --- a/ietf/ietfauth/utils.py +++ b/ietf/ietfauth/utils.py @@ -137,6 +137,10 @@ def has_role(user, role_names, *args, **kwargs): group__type="sdo", group__state="active", ), + "Liaison Coordinator": Q( + name="liaison_coordinator", + group__acronym="iab", + ), "Authorized Individual": Q( name="auth", group__type="sdo", diff --git a/ietf/ietfauth/views.py b/ietf/ietfauth/views.py index 23f66ce824..84d5490873 100644 --- a/ietf/ietfauth/views.py +++ b/ietf/ietfauth/views.py @@ -38,14 +38,14 @@ import importlib # needed if we revert to higher barrier for account creation -#from datetime import datetime as DateTime, timedelta as TimeDelta, date as Date +# from datetime import datetime as DateTime, timedelta as TimeDelta, date as Date from collections import defaultdict import django.core.signing from django import forms from django.contrib import messages from django.conf import settings -from django.contrib.auth import logout, update_session_auth_hash +from django.contrib.auth import logout, update_session_auth_hash, password_validation from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.hashers import identify_hasher @@ -80,7 +80,6 @@ # These are needed if we revert to the higher bar for account creation - def index(request): return render(request, 'registration/index.html') @@ -97,7 +96,7 @@ def index(request): # def ietf_login(request): # if not request.user.is_authenticated: # return HttpResponse("Not authenticated?", status=500) -# +# # redirect_to = request.REQUEST.get(REDIRECT_FIELD_NAME, '') # request.session.set_test_cookie() # return HttpResponseRedirect('/accounts/loggedin/?%s=%s' % (REDIRECT_FIELD_NAME, urlquote(redirect_to))) @@ -582,7 +581,6 @@ def test_email(request): return r - class AddReviewWishForm(forms.Form): doc = SearchableDocumentField(label="Document", doc_type="draft") team = forms.ModelChoiceField(queryset=Group.objects.all(), empty_label="(Choose review team)") @@ -696,7 +694,7 @@ def change_password(request): 'hasher': hasher, }) - + @login_required @person_required def change_username(request): @@ -764,6 +762,28 @@ def clean(self): ) return super().clean() + def confirm_login_allowed(self, user): + """Check whether a successfully authenticated user is permitted to log in""" + super().confirm_login_allowed(user) + # Optionally enforce password validation + if getattr(settings, "PASSWORD_POLICY_ENFORCE_AT_LOGIN", False): + try: + password_validation.validate_password( + self.cleaned_data["password"], user + ) + except ValidationError: + raise ValidationError( + # dict mapping field to error / error list + { + "__all__": ValidationError( + 'You entered your password correctly, but it does not ' + 'meet our current length and complexity requirements. ' + 'Please use the "Forgot your password?" button below to ' + 'set a new password for your account.' + ), + } + ) + class AnyEmailLoginView(LoginView): """LoginView that allows any email address as the username @@ -779,7 +799,7 @@ def form_valid(self, form): logout(self.request) # should not be logged in yet, but just in case... return render(self.request, "registration/missing_person.html") return super().form_valid(form) - + @login_required @person_required diff --git a/ietf/ietfauth/widgets.py b/ietf/ietfauth/widgets.py index c9a0523402..fd7fa16726 100644 --- a/ietf/ietfauth/widgets.py +++ b/ietf/ietfauth/widgets.py @@ -39,18 +39,19 @@ def render(self, name, value, attrs=None, renderer=None): strength_markup = """
-
+
- """ % ( + """.format( _("Warning"), _( 'This password would take to crack.' diff --git a/ietf/liaisons/admin.py b/ietf/liaisons/admin.py index c7cb7a4dae..21515ed1a3 100644 --- a/ietf/liaisons/admin.py +++ b/ietf/liaisons/admin.py @@ -24,7 +24,7 @@ class LiaisonStatementAdmin(admin.ModelAdmin): list_display = ['id', 'title', 'submitted', 'from_groups_short_display', 'purpose', 'related_to'] list_display_links = ['id', 'title'] ordering = ('title', ) - raw_id_fields = ('from_contact', 'attachments', 'from_groups', 'to_groups') + raw_id_fields = ('attachments', 'from_groups', 'to_groups') #filter_horizontal = ('from_groups', 'to_groups') inlines = [ RelatedLiaisonStatementInline, LiaisonStatementAttachmentInline ] @@ -50,4 +50,4 @@ class LiaisonStatementEventAdmin(admin.ModelAdmin): raw_id_fields = ["statement", "by"] admin.site.register(LiaisonStatement, LiaisonStatementAdmin) -admin.site.register(LiaisonStatementEvent, LiaisonStatementEventAdmin) \ No newline at end of file +admin.site.register(LiaisonStatementEvent, LiaisonStatementEventAdmin) diff --git a/ietf/liaisons/factories.py b/ietf/liaisons/factories.py index 6d93cf8cd2..ca588236e3 100644 --- a/ietf/liaisons/factories.py +++ b/ietf/liaisons/factories.py @@ -9,7 +9,7 @@ class Meta: skip_postgeneration_save = True title = factory.Faker('sentence') - from_contact = factory.SubFactory('ietf.person.factories.EmailFactory') + from_contact = factory.Faker('email') purpose_id = 'comment' body = factory.Faker('paragraph') state_id = 'posted' diff --git a/ietf/liaisons/forms.py b/ietf/liaisons/forms.py index 1af29044b3..7483981595 100644 --- a/ietf/liaisons/forms.py +++ b/ietf/liaisons/forms.py @@ -3,38 +3,33 @@ import io -import os import operator - -from typing import Union # pyflakes:ignore - +import os from email.utils import parseaddr +from functools import reduce +from typing import Union, Optional # pyflakes:ignore from django import forms from django.conf import settings -from django.core.exceptions import ObjectDoesNotExist, ValidationError -from django.forms.utils import ErrorList -from django.db.models import Q -#from django.forms.widgets import RadioFieldRenderer +from django.core.exceptions import ValidationError from django.core.validators import validate_email +from django.db.models import Q, QuerySet +from django.forms.utils import ErrorList from django_stubs_ext import QuerySetAny -import debug # pyflakes:ignore - +from ietf.doc.models import Document +from ietf.group.models import Group from ietf.ietfauth.utils import has_role -from ietf.name.models import DocRelationshipName -from ietf.liaisons.utils import get_person_for_user,is_authorized_individual -from ietf.liaisons.widgets import ButtonWidget,ShowAttachmentsWidget -from ietf.liaisons.models import (LiaisonStatement, - LiaisonStatementEvent,LiaisonStatementAttachment,LiaisonStatementPurposeName) from ietf.liaisons.fields import SearchableLiaisonStatementsField -from ietf.group.models import Group -from ietf.person.models import Email -from ietf.person.fields import SearchableEmailField -from ietf.doc.models import Document +from ietf.liaisons.models import (LiaisonStatement, + LiaisonStatementEvent, LiaisonStatementAttachment, LiaisonStatementPurposeName) +from ietf.liaisons.utils import get_person_for_user, is_authorized_individual, OUTGOING_LIAISON_ROLES, \ + INCOMING_LIAISON_ROLES +from ietf.liaisons.widgets import ButtonWidget, ShowAttachmentsWidget +from ietf.name.models import DocRelationshipName +from ietf.person.models import Person from ietf.utils.fields import DatepickerDateField, ModelMultipleChoiceField from ietf.utils.timezone import date_today, datetime_from_date, DEADLINE_TZINFO -from functools import reduce ''' NOTES: @@ -51,45 +46,106 @@ def liaison_manager_sdos(person): return Group.objects.filter(type="sdo", state="active", role__person=person, role__name="liaiman").distinct() + def flatten_choices(choices): - '''Returns a flat choice list given one with option groups defined''' + """Returns a flat choice list given one with option groups defined + + n.b., Django allows mixing grouped options and top-level options. This helper only supports + the non-mixed case where every option is in an option group. + """ flat = [] - for optgroup,options in choices: + for optgroup, options in choices: flat.extend(options) return flat + + +def choices_from_group_queryset(groups: QuerySet[Group]): + """Get choices list for internal IETF groups user is authorized to select -def get_internal_choices(user): - '''Returns the set of internal IETF groups the user has permissions for, as a list - of choices suitable for use in a select widget. If user == None, all active internal - groups are included.''' + Returns a grouped list of choices suitable for use with a ChoiceField. If user is None, + includes all groups. + """ + main = [] + areas = [] + wgs = [] + for g in groups.distinct().order_by("acronym"): + if g.acronym in ("ietf", "iesg", "iab"): + main.append((g.pk, f"The {g.acronym.upper()}")) + elif g.type_id == "area": + areas.append((g.pk, f"{g.acronym} - {g.name}")) + elif g.type_id == "wg": + wgs.append((g.pk, f"{g.acronym} - {g.name}")) choices = [] - groups = get_groups_for_person(user.person if user else None) - main = [ (g.pk, 'The {}'.format(g.acronym.upper())) for g in groups.filter(acronym__in=('ietf','iesg','iab')) ] - areas = [ (g.pk, '{} - {}'.format(g.acronym,g.name)) for g in groups.filter(type='area') ] - wgs = [ (g.pk, '{} - {}'.format(g.acronym,g.name)) for g in groups.filter(type='wg') ] - choices.append(('Main IETF Entities', main)) - choices.append(('IETF Areas', areas)) - choices.append(('IETF Working Groups', wgs )) + if len(main) > 0: + choices.append(("Main IETF Entities", main)) + if len(areas) > 0: + choices.append(("IETF Areas", areas)) + if len(wgs) > 0: + choices.append(("IETF Working Groups", wgs)) return choices -def get_groups_for_person(person): - '''Returns queryset of internal Groups the person has interesting roles in. - This is a refactor of IETFHierarchyManager.get_entities_for_person(). If Person - is None or Secretariat or Liaison Manager all internal IETF groups are returned. - ''' - if person == None or has_role(person.user, "Secretariat") or has_role(person.user, "Liaison Manager"): - # collect all internal IETF groups - queries = [Q(acronym__in=('ietf','iesg','iab')), - Q(type='area',state='active'), - Q(type='wg',state='active')] + +def all_internal_groups(): + """Get a queryset of all IETF groups suitable for LS To/From assignment""" + return Group.objects.filter( + Q(acronym__in=("ietf", "iesg", "iab")) + | Q(type="area", state="active") + | Q(type="wg", state="active") + ).distinct() + + +def internal_groups_for_person(person: Optional[Person]): + """Get a queryset of IETF groups suitable for LS To/From assignment by person""" + if person is None: + return Group.objects.none() # no person = no roles + + if has_role( + person.user, + ( + "Secretariat", + "IETF Chair", + "IAB Chair", + "IAB Executive Director", + "Liaison Manager", + "Liaison Coordinator", + "Authorized Individual", + ), + ): + return all_internal_groups() + # Interesting roles, as Group queries + queries = [ + Q(role__person=person, role__name="chair", acronym="ietf"), + Q(role__person=person, role__name__in=("chair", "execdir"), acronym="iab"), + Q(role__person=person, role__name="ad", type="area", state="active"), + Q( + role__person=person, + role__name__in=("chair", "secretary"), + type="wg", + state="active", + ), + Q( + parent__role__person=person, + parent__role__name="ad", + type="wg", + state="active", + ), + ] + if has_role(person.user, "Area Director"): + queries.append(Q(acronym__in=("ietf", "iesg"))) # AD can also choose these + return Group.objects.filter(reduce(operator.or_, queries)).distinct() + + +def external_groups_for_person(person): + """Get a queryset of external groups suitable for LS To/From assignment by person""" + filter_expr = Q(pk__in=[]) # start with no groups + # These roles can add all external sdo groups + if has_role(person.user, set(INCOMING_LIAISON_ROLES + OUTGOING_LIAISON_ROLES) - {"Liaison Manager", "Authorized Individual"}): + filter_expr |= Q(type="sdo") else: - # Interesting roles, as Group queries - queries = [Q(role__person=person,role__name='chair',acronym='ietf'), - Q(role__person=person,role__name__in=('chair','execdir'),acronym='iab'), - Q(role__person=person,role__name='ad',type='area',state='active'), - Q(role__person=person,role__name__in=('chair','secretary'),type='wg',state='active'), - Q(parent__role__person=person,parent__role__name='ad',type='wg',state='active')] - return Group.objects.filter(reduce(operator.or_,queries)).order_by('acronym').distinct() + # The person cannot add all external sdo groups; add any for which they are Liaison Manager + filter_expr |= Q(type="sdo", role__person=person, role__name__in=["auth", "liaiman"]) + return Group.objects.filter(state="active").filter(filter_expr).distinct().order_by("name") + def liaison_form_factory(request, type=None, **kwargs): """Returns appropriate Liaison entry form""" @@ -154,7 +210,7 @@ def get_results(self): query = self.cleaned_data.get('text') if query: q = (Q(title__icontains=query) | - Q(from_contact__address__icontains=query) | + Q(from_contact__icontains=query) | Q(to_contacts__icontains=query) | Q(other_identifiers__icontains=query) | Q(body__icontains=query) | @@ -216,13 +272,8 @@ class LiaisonModelForm(forms.ModelForm): '''Specify fields which require a custom widget or that are not part of the model. ''' from_groups = ModelMultipleChoiceField(queryset=Group.objects.all(),label='Groups',required=False) - from_groups.widget.attrs["class"] = "select2-field" - from_groups.widget.attrs['data-minimum-input-length'] = 0 - from_contact = forms.EmailField() # type: Union[forms.EmailField, SearchableEmailField] to_contacts = forms.CharField(label="Contacts", widget=forms.Textarea(attrs={'rows':'3', }), strip=False) to_groups = ModelMultipleChoiceField(queryset=Group.objects,label='Groups',required=False) - to_groups.widget.attrs["class"] = "select2-field" - to_groups.widget.attrs['data-minimum-input-length'] = 0 deadline = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label='Deadline', required=True) related_to = SearchableLiaisonStatementsField(label='Related Liaison Statement', required=False) submitted_date = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label='Submission date', required=True, initial=lambda: date_today(DEADLINE_TZINFO)) @@ -245,13 +296,17 @@ def __init__(self, user, *args, **kwargs): self.person = get_person_for_user(user) self.is_new = not self.instance.pk + self.fields["from_groups"].widget.attrs["class"] = "select2-field" + self.fields["from_groups"].widget.attrs["data-minimum-input-length"] = 0 self.fields["from_groups"].widget.attrs["data-placeholder"] = "Type in name to search for group" + self.fields["to_groups"].widget.attrs["class"] = "select2-field" + self.fields["to_groups"].widget.attrs["data-minimum-input-length"] = 0 self.fields["to_groups"].widget.attrs["data-placeholder"] = "Type in name to search for group" self.fields["to_contacts"].label = 'Contacts' self.fields["other_identifiers"].widget.attrs["rows"] = 2 - + # add email validators - for field in ['from_contact','to_contacts','technical_contacts','action_holder_contacts','cc_contacts']: + for field in ['to_contacts','technical_contacts','action_holder_contacts','cc_contacts']: if field in self.fields: self.fields[field].validators.append(validate_emails) @@ -270,18 +325,6 @@ def clean_to_groups(self): raise forms.ValidationError('You must specify a To Group') return to_groups - def clean_from_contact(self): - contact = self.cleaned_data.get('from_contact') - from_groups = self.cleaned_data.get('from_groups') - try: - email = Email.objects.get(address=contact) - if not email.origin: - email.origin = "liaison: %s" % (','.join([ g.acronym for g in from_groups.all() ])) - email.save() - except ObjectDoesNotExist: - raise forms.ValidationError('Email address does not exist') - return email - # Note to future person: This is the wrong place to fix the new lines # in cc_contacts and to_contacts. Those belong in the save function. # Or at least somewhere other than here. @@ -434,32 +477,39 @@ def is_approved(self): return True def get_post_only(self): - from_groups = self.cleaned_data.get('from_groups') - if has_role(self.user, "Secretariat") or is_authorized_individual(self.user,from_groups): + from_groups = self.cleaned_data.get("from_groups") + if ( + has_role(self.user, "Secretariat") + or has_role(self.user, "Liaison Coordinator") + or is_authorized_individual(self.user, from_groups) + ): return False return True def set_from_fields(self): - '''Set from_groups and from_contact options and initial value based on user - accessing the form.''' - if has_role(self.user, "Secretariat"): - queryset = Group.objects.filter(type="sdo", state="active").order_by('name') - else: - queryset = Group.objects.filter(type="sdo", state="active", role__person=self.person, role__name__in=("liaiman", "auth")).distinct().order_by('name') - self.fields['from_contact'].initial = self.person.role_set.filter(group=queryset[0]).first().email.address - self.fields['from_contact'].widget.attrs['disabled'] = True - self.fields['from_groups'].queryset = queryset - self.fields['from_groups'].widget.submitter = str(self.person) - + """Configure from "From" fields based on user roles""" + qs = external_groups_for_person(self.person) + self.fields["from_groups"].queryset = qs + self.fields["from_groups"].widget.submitter = str(self.person) # if there's only one possibility make it the default - if len(queryset) == 1: - self.fields['from_groups'].initial = queryset + if len(qs) == 1: + self.fields['from_groups'].initial = qs + + # Note that the IAB chair currently doesn't get to work with incoming liaison statements + if not ( + has_role(self.user, "Secretariat") + or has_role(self.user, "Liaison Coordinator") + ): + self.fields["from_contact"].initial = ( + self.person.role_set.filter(group=qs[0]).first().email.formatted_email() + ) + self.fields["from_contact"].widget.attrs["disabled"] = True def set_to_fields(self): '''Set to_groups and to_contacts options and initial value based on user accessing the form. For incoming Liaisons, to_groups choices is the full set. ''' - self.fields['to_groups'].choices = get_internal_choices(None) + self.fields['to_groups'].choices = choices_from_group_queryset(all_internal_groups()) class OutgoingLiaisonForm(LiaisonModelForm): @@ -473,46 +523,56 @@ def is_approved(self): return self.cleaned_data['approved'] def set_from_fields(self): - '''Set from_groups and from_contact options and initial value based on user - accessing the form''' - choices = get_internal_choices(self.user) - self.fields['from_groups'].choices = choices - - # set initial value if only one entry - flat_choices = flatten_choices(choices) + """Configure from "From" fields based on user roles""" + self.set_from_groups_field() + self.set_from_contact_field() + + def set_from_groups_field(self): + """Configure the from_groups field based on roles""" + grouped_choices = choices_from_group_queryset(internal_groups_for_person(self.person)) + flat_choices = flatten_choices(grouped_choices) if len(flat_choices) == 1: - self.fields['from_groups'].initial = [flat_choices[0][0]] - - if has_role(self.user, "Secretariat"): - self.fields['from_contact'] = SearchableEmailField(only_users=True) # secretariat can edit this field! - return - - if self.person.role_set.filter(name='liaiman',group__state='active'): - email = self.person.role_set.filter(name='liaiman',group__state='active').first().email.address - elif self.person.role_set.filter(name__in=('ad','chair'),group__state='active'): - email = self.person.role_set.filter(name__in=('ad','chair'),group__state='active').first().email.address + self.fields["from_groups"].choices = flat_choices + self.fields["from_groups"].initial = [flat_choices[0][0]] else: - email = self.person.email_address() + self.fields["from_groups"].choices = grouped_choices - # Non-secretariat user cannot change the from_contact field. Fill in its value. + def set_from_contact_field(self): + """Configure the from_contact field based on user roles""" + # Secretariat can set this to any valid address but gets no default + if has_role(self.user, "Secretariat"): + return + elif has_role(self.user, ["IAB Chair", "Liaison Coordinator"]): + self.fields["from_contact"].initial = "IAB Chair " + return + elif has_role(self.user, "IETF Chair"): + self.fields["from_contact"].initial = "IETF Chair " + return + # ... others have it set to the correct value and cannot change it self.fields['from_contact'].disabled = True - self.fields['from_contact'].initial = email - - def set_to_fields(self): - '''Set to_groups and to_contacts options and initial value based on user - accessing the form''' - # set options. if the user is a Liaison Manager and nothing more, reduce set to his SDOs - if has_role(self.user, "Liaison Manager") and not self.person.role_set.filter(name__in=('ad','chair'),group__state='active'): - queryset = Group.objects.filter(type="sdo", state="active", role__person=self.person, role__name="liaiman").distinct().order_by('name') + # Set up the querysets we might use - only evaluated as needed + liaison_manager_role = self.person.role_set.filter(name="liaiman", group__state="active") + chair_or_ad_role = self.person.role_set.filter( + name__in=("ad", "chair"), group__state="active" + ) + if liaison_manager_role.exists(): + from_contact_email = liaison_manager_role.first().email + elif chair_or_ad_role.exists(): + from_contact_email = chair_or_ad_role.first().email else: - # get all outgoing entities - queryset = Group.objects.filter(type="sdo", state="active").order_by('name') + from_contact_email = self.person.email() + self.fields['from_contact'].initial = from_contact_email.formatted_email() - self.fields['to_groups'].queryset = queryset + def set_to_fields(self): + """Configure the "To" fields based on user roles""" + qs = external_groups_for_person(self.person) + self.fields['to_groups'].queryset = qs # set initial if has_role(self.user, "Liaison Manager"): - self.fields['to_groups'].initial = [queryset.first()] + self.fields['to_groups'].initial = [ + qs.filter(role__person=self.person, role__name="liaiman").first() + ] class EditLiaisonForm(LiaisonModelForm): @@ -533,32 +593,20 @@ def save(self, *args, **kwargs): return self.instance def set_from_fields(self): - '''Set from_groups and from_contact options and initial value based on user - accessing the form.''' + """Configure from "From" fields based on user roles""" if self.instance.is_outgoing(): - self.fields['from_groups'].choices = get_internal_choices(self.user) + self.fields['from_groups'].choices = choices_from_group_queryset(internal_groups_for_person(self.person)) else: - if has_role(self.user, "Secretariat"): - queryset = Group.objects.filter(type="sdo").order_by('name') - else: - queryset = Group.objects.filter(type="sdo", role__person=self.person, role__name__in=("liaiman", "auth")).distinct().order_by('name') + self.fields["from_groups"].queryset = external_groups_for_person(self.person) + if not has_role(self.user, "Secretariat"): self.fields['from_contact'].widget.attrs['disabled'] = True - self.fields['from_groups'].queryset = queryset def set_to_fields(self): - '''Set to_groups and to_contacts options and initial value based on user - accessing the form. For incoming Liaisons, to_groups choices is the full set. - ''' + """Configure the "To" fields based on user roles""" if self.instance.is_outgoing(): - # if the user is a Liaison Manager and nothing more, reduce to set to his SDOs - if has_role(self.user, "Liaison Manager") and not self.person.role_set.filter(name__in=('ad','chair'),group__state='active'): - queryset = Group.objects.filter(type="sdo", role__person=self.person, role__name="liaiman").distinct().order_by('name') - else: - # get all outgoing entities - queryset = Group.objects.filter(type="sdo").order_by('name') - self.fields['to_groups'].queryset = queryset + self.fields['to_groups'].queryset = external_groups_for_person(self.person) else: - self.fields['to_groups'].choices = get_internal_choices(None) + self.fields['to_groups'].choices = choices_from_group_queryset(all_internal_groups()) class EditAttachmentForm(forms.Form): diff --git a/ietf/liaisons/migrations/0003_liaisonstatement_from_contact_tmp.py b/ietf/liaisons/migrations/0003_liaisonstatement_from_contact_tmp.py new file mode 100644 index 0000000000..de2ce7ff59 --- /dev/null +++ b/ietf/liaisons/migrations/0003_liaisonstatement_from_contact_tmp.py @@ -0,0 +1,22 @@ +# Copyright The IETF Trust 2025 All Rights Reserved +from django.db import migrations, models +import ietf.utils.validators + + +class Migration(migrations.Migration): + dependencies = [ + ("liaisons", "0002_alter_liaisonstatement_response_contacts"), + ] + + operations = [ + migrations.AddField( + model_name="liaisonstatement", + name="from_contact_tmp", + field=models.CharField( + blank=True, + help_text="Address of the formal sender of the statement", + max_length=512, + validators=[ietf.utils.validators.validate_mailbox_address], + ), + ), + ] diff --git a/ietf/liaisons/migrations/0004_populate_liaisonstatement_from_contact_tmp.py b/ietf/liaisons/migrations/0004_populate_liaisonstatement_from_contact_tmp.py new file mode 100644 index 0000000000..dbab326b0c --- /dev/null +++ b/ietf/liaisons/migrations/0004_populate_liaisonstatement_from_contact_tmp.py @@ -0,0 +1,60 @@ +# Copyright The IETF Trust 2025, All Rights Reserved +from itertools import islice + +from django.db import migrations + +from ietf.person.name import plain_name +from ietf.utils.mail import formataddr +from ietf.utils.validators import validate_mailbox_address + + +def forward(apps, schema_editor): + def _formatted_email(email): + """Format an email address to match Email.formatted_email()""" + person = email.person + if person: + return formataddr( + ( + # inlined Person.plain_name(), minus the caching + person.plain if person.plain else plain_name(person.name), + email.address, + ) + ) + return email.address + + def _batched(iterable, n): + """Split an iterable into lists of length <= n + + (based on itertools example code for batched(), which is added in py312) + """ + iterator = iter(iterable) + batch = list(islice(iterator, n)) # consumes first n iterations + while batch: + yield batch + batch = list(islice(iterator, n)) # consumes next n iterations + + LiaisonStatement = apps.get_model("liaisons", "LiaisonStatement") + LiaisonStatement.objects.update(from_contact_tmp="") # ensure they're all blank + for batch in _batched( + LiaisonStatement.objects.exclude(from_contact=None).select_related( + "from_contact" + ), + 100, + ): + for ls in batch: + ls.from_contact_tmp = _formatted_email(ls.from_contact) + validate_mailbox_address( + ls.from_contact_tmp + ) # be sure it's permitted before we accept it + + LiaisonStatement.objects.bulk_update(batch, fields=["from_contact_tmp"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("liaisons", "0003_liaisonstatement_from_contact_tmp"), + ] + + operations = [ + migrations.RunPython(forward), + ] diff --git a/ietf/liaisons/migrations/0005_replace_liaisonstatement_from_contact.py b/ietf/liaisons/migrations/0005_replace_liaisonstatement_from_contact.py new file mode 100644 index 0000000000..e1702ae3bc --- /dev/null +++ b/ietf/liaisons/migrations/0005_replace_liaisonstatement_from_contact.py @@ -0,0 +1,20 @@ +# Copyright The IETF Trust 2025 All Rights Reserved +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("liaisons", "0004_populate_liaisonstatement_from_contact_tmp"), + ] + + operations = [ + migrations.RemoveField( + model_name="liaisonstatement", + name="from_contact", + ), + migrations.RenameField( + model_name="liaisonstatement", + old_name="from_contact_tmp", + new_name="from_contact", + ), + ] diff --git a/ietf/liaisons/models.py b/ietf/liaisons/models.py index 2ad502102c..a2d79ea476 100644 --- a/ietf/liaisons/models.py +++ b/ietf/liaisons/models.py @@ -7,13 +7,14 @@ from django.db import models from django.utils.text import slugify -from ietf.person.models import Email, Person +from ietf.person.models import Person from ietf.name.models import (LiaisonStatementPurposeName, LiaisonStatementState, LiaisonStatementEventTypeName, LiaisonStatementTagName, DocRelationshipName) from ietf.doc.models import Document from ietf.group.models import Group from ietf.utils.models import ForeignKey +from ietf.utils.validators import validate_mailbox_address # maps (previous state id, new state id) to event type id STATE_EVENT_MAPPING = { @@ -29,7 +30,12 @@ class LiaisonStatement(models.Model): title = models.CharField(max_length=255) from_groups = models.ManyToManyField(Group, blank=True, related_name='liaisonstatement_from_set') - from_contact = ForeignKey(Email, blank=True, null=True) + from_contact = models.CharField( + blank=True, + max_length=512, + help_text="Address of the formal sender of the statement", + validators=(validate_mailbox_address,) + ) to_groups = models.ManyToManyField(Group, blank=True, related_name='liaisonstatement_to_set') to_contacts = models.CharField(max_length=2000, help_text="Contacts at recipient group") @@ -85,7 +91,7 @@ def name(self): if self.from_groups.count(): frm = ', '.join([i.acronym or i.name for i in self.from_groups.all()]) else: - frm = self.from_contact.person.name + frm = self.from_contact if self.to_groups.count(): to = ', '.join([i.acronym or i.name for i in self.to_groups.all()]) else: diff --git a/ietf/liaisons/resources.py b/ietf/liaisons/resources.py index 8f31ea3a64..02cd159a11 100644 --- a/ietf/liaisons/resources.py +++ b/ietf/liaisons/resources.py @@ -15,12 +15,10 @@ RelatedLiaisonStatement) -from ietf.person.resources import EmailResource from ietf.group.resources import GroupResource from ietf.name.resources import LiaisonStatementPurposeNameResource, LiaisonStatementTagNameResource, LiaisonStatementStateResource from ietf.doc.resources import DocumentResource class LiaisonStatementResource(ModelResource): - from_contact = ToOneField(EmailResource, 'from_contact', null=True) purpose = ToOneField(LiaisonStatementPurposeNameResource, 'purpose') state = ToOneField(LiaisonStatementStateResource, 'state') from_groups = ToManyField(GroupResource, 'from_groups', null=True) @@ -36,6 +34,7 @@ class Meta: filtering = { "id": ALL, "title": ALL, + "from_contact": ALL, "to_contacts": ALL, "response_contacts": ALL, "technical_contacts": ALL, @@ -44,9 +43,6 @@ class Meta: "deadline": ALL, "other_identifiers": ALL, "body": ALL, - "from_name": ALL, - "to_name": ALL, - "from_contact": ALL_WITH_RELATIONS, "purpose": ALL_WITH_RELATIONS, "state": ALL_WITH_RELATIONS, "from_groups": ALL_WITH_RELATIONS, diff --git a/ietf/liaisons/tests.py b/ietf/liaisons/tests.py index 1742687f14..1d6cfe0c14 100644 --- a/ietf/liaisons/tests.py +++ b/ietf/liaisons/tests.py @@ -462,11 +462,12 @@ def test_edit_liaison(self): def test_incoming_access(self): - '''Ensure only Secretariat, Liaison Managers, and Authorized Individuals + '''Ensure only Secretariat, Liaison Managers, Liaison Coordinators, and Authorized Individuals have access to incoming liaisons. ''' sdo = RoleFactory(name_id='liaiman',group__type_id='sdo', person__user__username='ulm-liaiman').group RoleFactory(name_id='auth',group=sdo,person__user__username='ulm-auth') + RoleFactory(name_id='liaison_coordinator', group__acronym='iab', person__user__username='liaison-coordinator') stmt = LiaisonStatementFactory(from_groups=[sdo,]) LiaisonStatementEventFactory(statement=stmt,type_id='posted') RoleFactory(name_id='chair',person__user__username='marschairman',group__acronym='mars') @@ -499,6 +500,15 @@ def test_incoming_access(self): r = self.client.get(addurl) self.assertEqual(r.status_code, 200) + # Liaison Coordinator has access + self.client.login(username="liaison-coordinator", password="liaison-coordinator+password") + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertEqual(len(q('a.btn:contains("New incoming liaison")')), 1) + r = self.client.get(addurl) + self.assertEqual(r.status_code, 200) + # Authorized Individual has access self.client.login(username="ulm-auth", password="ulm-auth+password") r = self.client.get(url) @@ -521,6 +531,7 @@ def test_outgoing_access(self): sdo = RoleFactory(name_id='liaiman',group__type_id='sdo', person__user__username='ulm-liaiman').group RoleFactory(name_id='auth',group=sdo,person__user__username='ulm-auth') + RoleFactory(name_id='liaison_coordinator', group__acronym='iab', person__user__username='liaison-coordinator') mars = RoleFactory(name_id='chair',person__user__username='marschairman',group__acronym='mars').group RoleFactory(name_id='secr',group=mars,person__user__username='mars-secr') RoleFactory(name_id='execdir',group=Group.objects.get(acronym='iab'),person__user__username='iab-execdir') @@ -599,6 +610,15 @@ def test_outgoing_access(self): r = self.client.get(addurl) self.assertEqual(r.status_code, 200) + # Liaison Coordinator has access + self.assertTrue(self.client.login(username="liaison-coordinator", password="liaison-coordinator+password")) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertEqual(len(q('a.btn:contains("New outgoing liaison")')), 1) + r = self.client.get(addurl) + self.assertEqual(r.status_code, 200) + # Authorized Individual has no access self.assertTrue(self.client.login(username="ulm-auth", password="ulm-auth+password")) r = self.client.get(url) @@ -740,7 +760,7 @@ def test_add_incoming_liaison(self): l = LiaisonStatement.objects.all().order_by("-id")[0] self.assertEqual(l.from_groups.count(),2) - self.assertEqual(l.from_contact.address, submitter.email_address()) + self.assertEqual(l.from_contact, submitter.email_address()) self.assertSequenceEqual(l.to_groups.all(),[to_group]) self.assertEqual(l.technical_contacts, "technical_contact@example.com") self.assertEqual(l.action_holder_contacts, "action_holder_contacts@example.com") @@ -825,7 +845,7 @@ def test_add_outgoing_liaison(self): l = LiaisonStatement.objects.all().order_by("-id")[0] self.assertSequenceEqual(l.from_groups.all(), [from_group]) - self.assertEqual(l.from_contact.address, submitter.email_address()) + self.assertEqual(l.from_contact, submitter.email_address()) self.assertSequenceEqual(l.to_groups.all(), [to_group]) self.assertEqual(l.to_contacts, "to_contacts@example.com") self.assertEqual(l.technical_contacts, "technical_contact@example.com") @@ -901,7 +921,7 @@ def test_liaison_add_attachment(self): file.name = "upload.txt" post_data = dict( from_groups = ','.join([ str(x.pk) for x in liaison.from_groups.all() ]), - from_contact = liaison.from_contact.address, + from_contact = liaison.from_contact, to_groups = ','.join([ str(x.pk) for x in liaison.to_groups.all() ]), to_contacts = 'to_contacts@example.com', purpose = liaison.purpose.slug, diff --git a/ietf/liaisons/tests_forms.py b/ietf/liaisons/tests_forms.py new file mode 100644 index 0000000000..c2afddea65 --- /dev/null +++ b/ietf/liaisons/tests_forms.py @@ -0,0 +1,229 @@ +# Copyright The IETF Trust 2025, All Rights Reserved +from ietf.group.factories import GroupFactory, RoleFactory +from ietf.group.models import Group +from ietf.liaisons.forms import ( + flatten_choices, + choices_from_group_queryset, + all_internal_groups, + internal_groups_for_person, + external_groups_for_person, +) +from ietf.person.factories import PersonFactory +from ietf.person.models import Person +from ietf.utils.test_utils import TestCase + + +class HelperTests(TestCase): + @staticmethod + def _alphabetically_by_acronym(group_list): + return sorted(group_list, key=lambda item: item.acronym) + + def test_choices_from_group_queryset(self): + main_groups = list(Group.objects.filter(acronym__in=["ietf", "iab"])) + areas = GroupFactory.create_batch(2, type_id="area") + wgs = GroupFactory.create_batch(2) + + # No groups + self.assertEqual( + choices_from_group_queryset(Group.objects.none()), + [], + ) + + # Main groups only + choices = choices_from_group_queryset( + Group.objects.filter(pk__in=[g.pk for g in main_groups]) + ) + self.assertEqual(len(choices), 1, "show one optgroup, hide empty ones") + self.assertEqual(choices[0][0], "Main IETF Entities") + self.assertEqual( + [val for val, _ in choices[0][1]], # extract the choice value + [g.pk for g in self._alphabetically_by_acronym(main_groups)], + ) + + # Area groups only + choices = choices_from_group_queryset( + Group.objects.filter(pk__in=[g.pk for g in areas]) + ) + self.assertEqual(len(choices), 1, "show one optgroup, hide empty ones") + self.assertEqual(choices[0][0], "IETF Areas") + self.assertEqual( + [val for val, _ in choices[0][1]], # extract the choice value + [g.pk for g in self._alphabetically_by_acronym(areas)], + ) + + # WGs only + choices = choices_from_group_queryset( + Group.objects.filter(pk__in=[g.pk for g in wgs]) + ) + self.assertEqual(len(choices), 1, "show one optgroup, hide empty ones") + self.assertEqual(choices[0][0], "IETF Working Groups") + self.assertEqual( + [val for val, _ in choices[0][1]], # extract the choice value + [g.pk for g in self._alphabetically_by_acronym(wgs)], + ) + + # All together + choices = choices_from_group_queryset( + Group.objects.filter(pk__in=[g.pk for g in main_groups + areas + wgs]) + ) + self.assertEqual(len(choices), 3, "show all three optgroups") + self.assertEqual( + [optgroup_label for optgroup_label, _ in choices], + ["Main IETF Entities", "IETF Areas", "IETF Working Groups"], + ) + self.assertEqual( + [val for val, _ in choices[0][1]], # extract the choice value + [g.pk for g in self._alphabetically_by_acronym(main_groups)], + ) + self.assertEqual( + [val for val, _ in choices[1][1]], # extract the choice value + [g.pk for g in self._alphabetically_by_acronym(areas)], + ) + self.assertEqual( + [val for val, _ in choices[2][1]], # extract the choice value + [g.pk for g in self._alphabetically_by_acronym(wgs)], + ) + + def test_all_internal_groups(self): + # test relies on the data created in ietf.utils.test_data.make_immutable_test_data() + self.assertCountEqual( + all_internal_groups().values_list("acronym", flat=True), + {"ietf", "iab", "iesg", "farfut", "ops", "sops"}, + ) + + def test_internal_groups_for_person(self): + # test relies on the data created in ietf.utils.test_data.make_immutable_test_data() + # todo add liaison coordinator when modeled + RoleFactory( + name_id="execdir", + group=Group.objects.get(acronym="iab"), + person__user__username="iab-execdir", + ) + RoleFactory( + name_id="auth", + group__type_id="sdo", + group__acronym="sdo", + person__user__username="sdo-authperson", + ) + + self.assertQuerysetEqual( + internal_groups_for_person(None), + Group.objects.none(), + msg="no Person means no groups", + ) + self.assertQuerysetEqual( + internal_groups_for_person(PersonFactory()), + Group.objects.none(), + msg="no Role means no groups", + ) + + for username in ( + "secretary", + "ietf-chair", + "iab-chair", + "iab-execdir", + "sdo-authperson", + ): + returned_queryset = internal_groups_for_person( + Person.objects.get(user__username=username) + ) + self.assertCountEqual( + returned_queryset.values_list("acronym", flat=True), + {"ietf", "iab", "iesg", "farfut", "ops", "sops"}, + f"{username} should get all groups", + ) + + # "ops-ad" user is the AD of the "ops" area, which contains the "sops" wg + self.assertCountEqual( + internal_groups_for_person( + Person.objects.get(user__username="ops-ad") + ).values_list("acronym", flat=True), + {"ietf", "iesg", "ops", "sops"}, + "area director should get only their area, its wgs, and the ietf/iesg groups", + ) + + self.assertCountEqual( + internal_groups_for_person( + Person.objects.get(user__username="sopschairman"), + ).values_list("acronym", flat=True), + {"sops"}, + "wg chair should get only their wg", + ) + + def test_external_groups_for_person(self): + RoleFactory( + name_id="execdir", + group=Group.objects.get(acronym="iab"), + person__user__username="iab-execdir", + ) + RoleFactory(name_id="liaison_coordinator", group__acronym="iab", person__user__username="liaison-coordinator") + the_sdo = GroupFactory(type_id="sdo", acronym="the-sdo") + liaison_manager = RoleFactory(name_id="liaiman", group=the_sdo).person + authperson = RoleFactory(name_id="auth", group=the_sdo).person + + GroupFactory(acronym="other-sdo", type_id="sdo") + for username in ( + "secretary", + "ietf-chair", + "iab-chair", + "iab-execdir", + "liaison-coordinator", + "ad", + "sopschairman", + "sopssecretary", + ): + person = Person.objects.get(user__username=username) + self.assertCountEqual( + external_groups_for_person( + person, + ).values_list("acronym", flat=True), + {"the-sdo", "other-sdo"}, + f"{username} should get all SDO groups", + ) + tmp_role = RoleFactory(name_id="chair", group__type_id="wg", person=person) + self.assertCountEqual( + external_groups_for_person( + person, + ).values_list("acronym", flat=True), + {"the-sdo", "other-sdo"}, + f"{username} should still get all SDO groups when they also a liaison manager", + ) + tmp_role.delete() + + self.assertCountEqual( + external_groups_for_person(liaison_manager).values_list( + "acronym", flat=True + ), + {"the-sdo"}, + "liaison manager should get only their SDO group", + ) + self.assertCountEqual( + external_groups_for_person(authperson).values_list("acronym", flat=True), + {"the-sdo"}, + "authorized individual should get only their SDO group", + ) + + def test_flatten_choices(self): + self.assertEqual(flatten_choices([]), []) + self.assertEqual( + flatten_choices( + ( + ("group A", ()), + ("group B", (("val0", "label0"), ("val1", "label1"))), + ("group C", (("val2", "label2"),)), + ) + ), + [("val0", "label0"), ("val1", "label1"), ("val2", "label2")], + ) + + +class IncomingLiaisonFormTests(TestCase): + pass + + +class OutgoingLiaisonFormTests(TestCase): + pass + + +class EditLiaisonFormTests(TestCase): + pass diff --git a/ietf/liaisons/utils.py b/ietf/liaisons/utils.py index df48831917..ea06c5988e 100644 --- a/ietf/liaisons/utils.py +++ b/ietf/liaisons/utils.py @@ -4,6 +4,22 @@ from ietf.liaisons.models import LiaisonStatement from ietf.ietfauth.utils import has_role, passes_test_decorator +# Roles allowed to create and manage outgoing liaison statements. +OUTGOING_LIAISON_ROLES = [ + "Area Director", + "IAB Chair", + "IAB Executive Director", + "IETF Chair", + "Liaison Manager", + "Liaison Coordinator", + "Secretariat", + "WG Chair", + "WG Secretary", +] + +# Roles allowed to create and manage incoming liaison statements. +INCOMING_LIAISON_ROLES = ["Authorized Individual", "Liaison Manager", "Liaison Coordinator", "Secretariat"] + can_submit_liaison_required = passes_test_decorator( lambda u, *args, **kwargs: can_add_liaison(u), "Restricted to participants who are authorized to submit liaison statements on behalf of the various IETF entities") @@ -30,13 +46,13 @@ def can_edit_liaison(user, liaison): '''Returns True if user has edit / approval authority. True if: - - user is Secretariat + - user is Secretariat or Liaison Coordinator - liaison is outgoing and user has approval authority - user is liaison manager of all SDOs involved ''' if not user.is_authenticated: return False - if has_role(user, "Secretariat"): + if has_role(user, "Secretariat") or has_role(user, "Liaison Coordinator"): return True if liaison.is_outgoing() and liaison in approvable_liaison_statements(user): @@ -59,11 +75,10 @@ def get_person_for_user(user): return None def can_add_outgoing_liaison(user): - return has_role(user, ["Area Director","WG Chair","WG Secretary","IETF Chair","IAB Chair", - "IAB Executive Director","Liaison Manager","Secretariat"]) + return has_role(user, OUTGOING_LIAISON_ROLES) def can_add_incoming_liaison(user): - return has_role(user, ["Liaison Manager","Authorized Individual","Secretariat"]) + return has_role(user, INCOMING_LIAISON_ROLES) def can_add_liaison(user): return can_add_incoming_liaison(user) or can_add_outgoing_liaison(user) diff --git a/ietf/liaisons/views.py b/ietf/liaisons/views.py index a8e80a5194..1b7e8d63bb 100644 --- a/ietf/liaisons/views.py +++ b/ietf/liaisons/views.py @@ -57,7 +57,7 @@ def _can_take_care(liaison, user): return False if user.is_authenticated: - if has_role(user, "Secretariat"): + if has_role(user, "Secretariat") or has_role(user, "Liaison Coordinator"): return True else: return _find_person_in_emails(liaison, get_person_for_user(user)) @@ -196,7 +196,13 @@ def post_only(group,person): - Authorized Individuals have full access for the group they are associated with - Liaison Managers can post only ''' - if group.type_id == 'sdo' and ( not(has_role(person.user,"Secretariat") or group.role_set.filter(name='auth',person=person)) ): + if group.type_id == "sdo" and ( + not ( + has_role(person.user, "Secretariat") + or has_role(person.user, "Liaison Coordinator") + or group.role_set.filter(name="auth", person=person) + ) + ): return True else: return False diff --git a/ietf/meeting/forms.py b/ietf/meeting/forms.py index e1d1e90b8d..b6b1a1591f 100644 --- a/ietf/meeting/forms.py +++ b/ietf/meeting/forms.py @@ -22,7 +22,7 @@ from ietf.doc.models import Document, State, NewRevisionDocEvent from ietf.group.models import Group from ietf.group.utils import groups_managed_by -from ietf.meeting.models import Session, Meeting, Schedule, countries, timezones, TimeSlot, Room +from ietf.meeting.models import Session, Meeting, Schedule, COUNTRIES, TIMEZONES, TimeSlot, Room from ietf.meeting.helpers import get_next_interim_number, make_materials_directories from ietf.meeting.helpers import is_interim_meeting_approved, get_next_agenda_name from ietf.message.models import Message @@ -38,11 +38,6 @@ from ietf.utils.validators import ( validate_file_size, validate_mime_type, validate_file_extension, validate_no_html_frame) -# need to insert empty option for use in ChoiceField -# countries.insert(0, ('', '-'*9 )) -countries.insert(0, ('', '-' * 9)) -timezones.insert(0, ('', '-' * 9)) - # ------------------------------------------------- # Helpers # ------------------------------------------------- @@ -140,12 +135,12 @@ class InterimMeetingModelForm(forms.ModelForm): approved = forms.BooleanField(required=False) city = forms.CharField(max_length=255, required=False) city.widget.attrs['placeholder'] = "City" - country = forms.ChoiceField(choices=countries, required=False) + country = forms.ChoiceField(choices=COUNTRIES, required=False) country.widget.attrs['class'] = "select2-field" country.widget.attrs['data-max-entries'] = 1 country.widget.attrs['data-placeholder'] = "Country" country.widget.attrs['data-minimum-input-length'] = 0 - time_zone = forms.ChoiceField(choices=timezones) + time_zone = forms.ChoiceField(choices=TIMEZONES) time_zone.widget.attrs['class'] = "select2-field" time_zone.widget.attrs['data-max-entries'] = 1 time_zone.widget.attrs['data-minimum-input-length'] = 0 diff --git a/ietf/meeting/migrations/0014_alter_floorplan_image.py b/ietf/meeting/migrations/0014_alter_floorplan_image.py new file mode 100644 index 0000000000..e125625edc --- /dev/null +++ b/ietf/meeting/migrations/0014_alter_floorplan_image.py @@ -0,0 +1,25 @@ +# Copyright The IETF Trust 2025, All Rights Reserved + +from django.db import migrations, models +import ietf.meeting.models +import ietf.utils.storage + + +class Migration(migrations.Migration): + dependencies = [ + ("meeting", "0013_correct_reg_checkedin"), + ] + + operations = [ + migrations.AlterField( + model_name="floorplan", + name="image", + field=models.ImageField( + default=None, + storage=ietf.utils.storage.BlobShadowFileSystemStorage( + kind="", location=None + ), + upload_to=ietf.meeting.models.floorplan_path, + ), + ), + ] diff --git a/ietf/meeting/migrations/0015_alter_meeting_time_zone.py b/ietf/meeting/migrations/0015_alter_meeting_time_zone.py new file mode 100644 index 0000000000..2a4b7859ee --- /dev/null +++ b/ietf/meeting/migrations/0015_alter_meeting_time_zone.py @@ -0,0 +1,451 @@ +# Copyright The IETF Trust 2025, All Rights Reserved + +from django.db import migrations, models + + +def forward(apps, schema_editor): + """Migrate 'GMT' meeting time zones to 'UTC'""" + Meeting = apps.get_model("meeting", "Meeting") + Meeting.objects.filter(time_zone="GMT").update(time_zone="UTC") + + +def reverse(apps, schema_editor): + pass # nothing to do + + +class Migration(migrations.Migration): + + dependencies = [ + ("meeting", "0014_alter_floorplan_image"), + ] + + operations = [ + migrations.RunPython(forward, reverse), + migrations.AlterField( + model_name="meeting", + name="time_zone", + field=models.CharField( + choices=[ + ("", "---------"), + ("Africa/Abidjan", "Africa/Abidjan"), + ("Africa/Accra", "Africa/Accra"), + ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), + ("Africa/Algiers", "Africa/Algiers"), + ("Africa/Asmara", "Africa/Asmara"), + ("Africa/Bamako", "Africa/Bamako"), + ("Africa/Bangui", "Africa/Bangui"), + ("Africa/Banjul", "Africa/Banjul"), + ("Africa/Bissau", "Africa/Bissau"), + ("Africa/Blantyre", "Africa/Blantyre"), + ("Africa/Brazzaville", "Africa/Brazzaville"), + ("Africa/Bujumbura", "Africa/Bujumbura"), + ("Africa/Cairo", "Africa/Cairo"), + ("Africa/Casablanca", "Africa/Casablanca"), + ("Africa/Ceuta", "Africa/Ceuta"), + ("Africa/Conakry", "Africa/Conakry"), + ("Africa/Dakar", "Africa/Dakar"), + ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), + ("Africa/Djibouti", "Africa/Djibouti"), + ("Africa/Douala", "Africa/Douala"), + ("Africa/El_Aaiun", "Africa/El_Aaiun"), + ("Africa/Freetown", "Africa/Freetown"), + ("Africa/Gaborone", "Africa/Gaborone"), + ("Africa/Harare", "Africa/Harare"), + ("Africa/Johannesburg", "Africa/Johannesburg"), + ("Africa/Juba", "Africa/Juba"), + ("Africa/Kampala", "Africa/Kampala"), + ("Africa/Khartoum", "Africa/Khartoum"), + ("Africa/Kigali", "Africa/Kigali"), + ("Africa/Kinshasa", "Africa/Kinshasa"), + ("Africa/Lagos", "Africa/Lagos"), + ("Africa/Libreville", "Africa/Libreville"), + ("Africa/Lome", "Africa/Lome"), + ("Africa/Luanda", "Africa/Luanda"), + ("Africa/Lubumbashi", "Africa/Lubumbashi"), + ("Africa/Lusaka", "Africa/Lusaka"), + ("Africa/Malabo", "Africa/Malabo"), + ("Africa/Maputo", "Africa/Maputo"), + ("Africa/Maseru", "Africa/Maseru"), + ("Africa/Mbabane", "Africa/Mbabane"), + ("Africa/Mogadishu", "Africa/Mogadishu"), + ("Africa/Monrovia", "Africa/Monrovia"), + ("Africa/Nairobi", "Africa/Nairobi"), + ("Africa/Ndjamena", "Africa/Ndjamena"), + ("Africa/Niamey", "Africa/Niamey"), + ("Africa/Nouakchott", "Africa/Nouakchott"), + ("Africa/Ouagadougou", "Africa/Ouagadougou"), + ("Africa/Porto-Novo", "Africa/Porto-Novo"), + ("Africa/Sao_Tome", "Africa/Sao_Tome"), + ("Africa/Tripoli", "Africa/Tripoli"), + ("Africa/Tunis", "Africa/Tunis"), + ("Africa/Windhoek", "Africa/Windhoek"), + ("America/Adak", "America/Adak"), + ("America/Anchorage", "America/Anchorage"), + ("America/Anguilla", "America/Anguilla"), + ("America/Antigua", "America/Antigua"), + ("America/Araguaina", "America/Araguaina"), + ( + "America/Argentina/Buenos_Aires", + "America/Argentina/Buenos_Aires", + ), + ("America/Argentina/Catamarca", "America/Argentina/Catamarca"), + ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), + ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), + ("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"), + ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), + ( + "America/Argentina/Rio_Gallegos", + "America/Argentina/Rio_Gallegos", + ), + ("America/Argentina/Salta", "America/Argentina/Salta"), + ("America/Argentina/San_Juan", "America/Argentina/San_Juan"), + ("America/Argentina/San_Luis", "America/Argentina/San_Luis"), + ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), + ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), + ("America/Aruba", "America/Aruba"), + ("America/Asuncion", "America/Asuncion"), + ("America/Atikokan", "America/Atikokan"), + ("America/Bahia", "America/Bahia"), + ("America/Bahia_Banderas", "America/Bahia_Banderas"), + ("America/Barbados", "America/Barbados"), + ("America/Belem", "America/Belem"), + ("America/Belize", "America/Belize"), + ("America/Blanc-Sablon", "America/Blanc-Sablon"), + ("America/Boa_Vista", "America/Boa_Vista"), + ("America/Bogota", "America/Bogota"), + ("America/Boise", "America/Boise"), + ("America/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Costa_Rica", "America/Costa_Rica"), + ("America/Creston", "America/Creston"), + ("America/Cuiaba", "America/Cuiaba"), + ("America/Curacao", "America/Curacao"), + ("America/Danmarkshavn", "America/Danmarkshavn"), + ("America/Dawson", "America/Dawson"), + ("America/Dawson_Creek", "America/Dawson_Creek"), + ("America/Denver", "America/Denver"), + ("America/Detroit", "America/Detroit"), + ("America/Dominica", "America/Dominica"), + ("America/Edmonton", "America/Edmonton"), + ("America/Eirunepe", "America/Eirunepe"), + ("America/El_Salvador", "America/El_Salvador"), + ("America/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("America/Goose_Bay", "America/Goose_Bay"), + ("America/Grand_Turk", "America/Grand_Turk"), + ("America/Grenada", "America/Grenada"), + ("America/Guadeloupe", "America/Guadeloupe"), + ("America/Guatemala", "America/Guatemala"), + ("America/Guayaquil", "America/Guayaquil"), + ("America/Guyana", "America/Guyana"), + ("America/Halifax", "America/Halifax"), + ("America/Havana", "America/Havana"), + ("America/Hermosillo", "America/Hermosillo"), + ("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"), + ("America/Indiana/Knox", "America/Indiana/Knox"), + ("America/Indiana/Marengo", "America/Indiana/Marengo"), + ("America/Indiana/Petersburg", "America/Indiana/Petersburg"), + ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), + ("America/Indiana/Vevay", "America/Indiana/Vevay"), + ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), + ("America/Indiana/Winamac", "America/Indiana/Winamac"), + ("America/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Juneau", "America/Juneau"), + ("America/Kentucky/Louisville", "America/Kentucky/Louisville"), + ("America/Kentucky/Monticello", "America/Kentucky/Monticello"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("America/Maceio", "America/Maceio"), + ("America/Managua", "America/Managua"), + ("America/Manaus", "America/Manaus"), + ("America/Martinique", "America/Martinique"), + ("America/Matamoros", "America/Matamoros"), + ("America/Mazatlan", "America/Mazatlan"), + ("America/Menominee", "America/Menominee"), + ("America/Merida", "America/Merida"), + ("America/Metlakatla", "America/Metlakatla"), + ("America/Mexico_City", "America/Mexico_City"), + ("America/Miquelon", "America/Miquelon"), + ("America/Moncton", "America/Moncton"), + ("America/Monterrey", "America/Monterrey"), + ("America/Montevideo", "America/Montevideo"), + ("America/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("America/Nipigon", "America/Nipigon"), + ("America/Nome", "America/Nome"), + ("America/Noronha", "America/Noronha"), + ("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"), + ("America/North_Dakota/Center", "America/North_Dakota/Center"), + ( + "America/North_Dakota/New_Salem", + "America/North_Dakota/New_Salem", + ), + ("America/Nuuk", "America/Nuuk"), + ("America/Ojinaga", "America/Ojinaga"), + ("America/Panama", "America/Panama"), + ("America/Pangnirtung", "America/Pangnirtung"), + ("America/Paramaribo", "America/Paramaribo"), + ("America/Phoenix", "America/Phoenix"), + ("America/Port-au-Prince", "America/Port-au-Prince"), + ("America/Port_of_Spain", "America/Port_of_Spain"), + ("America/Porto_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("America/Rainy_River", "America/Rainy_River"), + ("America/Rankin_Inlet", "America/Rankin_Inlet"), + ("America/Recife", "America/Recife"), + ("America/Regina", "America/Regina"), + ("America/Resolute", "America/Resolute"), + ("America/Rio_Branco", "America/Rio_Branco"), + ("America/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("America/Sitka", "America/Sitka"), + ("America/St_Johns", "America/St_Johns"), + ("America/St_Kitts", "America/St_Kitts"), + ("America/St_Lucia", "America/St_Lucia"), + ("America/St_Thomas", "America/St_Thomas"), + ("America/St_Vincent", "America/St_Vincent"), + ("America/Swift_Current", "America/Swift_Current"), + ("America/Tegucigalpa", "America/Tegucigalpa"), + ("America/Thule", "America/Thule"), + ("America/Thunder_Bay", "America/Thunder_Bay"), + ("America/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("America/Yellowknife", "America/Yellowknife"), + ("Antarctica/Casey", "Antarctica/Casey"), + ("Antarctica/Davis", "Antarctica/Davis"), + ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), + ("Antarctica/Macquarie", "Antarctica/Macquarie"), + ("Antarctica/Mawson", "Antarctica/Mawson"), + ("Antarctica/McMurdo", "Antarctica/McMurdo"), + ("Antarctica/Palmer", "Antarctica/Palmer"), + ("Antarctica/Rothera", "Antarctica/Rothera"), + ("Antarctica/Syowa", "Antarctica/Syowa"), + ("Antarctica/Troll", "Antarctica/Troll"), + ("Antarctica/Vostok", "Antarctica/Vostok"), + ("Asia/Aden", "Asia/Aden"), + ("Asia/Almaty", "Asia/Almaty"), + ("Asia/Amman", "Asia/Amman"), + ("Asia/Anadyr", "Asia/Anadyr"), + ("Asia/Aqtau", "Asia/Aqtau"), + ("Asia/Aqtobe", "Asia/Aqtobe"), + ("Asia/Ashgabat", "Asia/Ashgabat"), + ("Asia/Atyrau", "Asia/Atyrau"), + ("Asia/Baghdad", "Asia/Baghdad"), + ("Asia/Bahrain", "Asia/Bahrain"), + ("Asia/Baku", "Asia/Baku"), + ("Asia/Bangkok", "Asia/Bangkok"), + ("Asia/Barnaul", "Asia/Barnaul"), + ("Asia/Beirut", "Asia/Beirut"), + ("Asia/Bishkek", "Asia/Bishkek"), + ("Asia/Brunei", "Asia/Brunei"), + ("Asia/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Colombo", "Asia/Colombo"), + ("Asia/Damascus", "Asia/Damascus"), + ("Asia/Dhaka", "Asia/Dhaka"), + ("Asia/Dili", "Asia/Dili"), + ("Asia/Dubai", "Asia/Dubai"), + ("Asia/Dushanbe", "Asia/Dushanbe"), + ("Asia/Famagusta", "Asia/Famagusta"), + ("Asia/Gaza", "Asia/Gaza"), + ("Asia/Hebron", "Asia/Hebron"), + ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), + ("Asia/Hong_Kong", "Asia/Hong_Kong"), + ("Asia/Hovd", "Asia/Hovd"), + ("Asia/Irkutsk", "Asia/Irkutsk"), + ("Asia/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("Asia/Khandyga", "Asia/Khandyga"), + ("Asia/Kolkata", "Asia/Kolkata"), + ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), + ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), + ("Asia/Kuching", "Asia/Kuching"), + ("Asia/Kuwait", "Asia/Kuwait"), + ("Asia/Macau", "Asia/Macau"), + ("Asia/Magadan", "Asia/Magadan"), + ("Asia/Makassar", "Asia/Makassar"), + ("Asia/Manila", "Asia/Manila"), + ("Asia/Muscat", "Asia/Muscat"), + ("Asia/Nicosia", "Asia/Nicosia"), + ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), + ("Asia/Novosibirsk", "Asia/Novosibirsk"), + ("Asia/Omsk", "Asia/Omsk"), + ("Asia/Oral", "Asia/Oral"), + ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), + ("Asia/Pontianak", "Asia/Pontianak"), + ("Asia/Pyongyang", "Asia/Pyongyang"), + ("Asia/Qatar", "Asia/Qatar"), + ("Asia/Qostanay", "Asia/Qostanay"), + ("Asia/Qyzylorda", "Asia/Qyzylorda"), + ("Asia/Riyadh", "Asia/Riyadh"), + ("Asia/Sakhalin", "Asia/Sakhalin"), + ("Asia/Samarkand", "Asia/Samarkand"), + ("Asia/Seoul", "Asia/Seoul"), + ("Asia/Shanghai", "Asia/Shanghai"), + ("Asia/Singapore", "Asia/Singapore"), + ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), + ("Asia/Taipei", "Asia/Taipei"), + ("Asia/Tashkent", "Asia/Tashkent"), + ("Asia/Tbilisi", "Asia/Tbilisi"), + ("Asia/Tehran", "Asia/Tehran"), + ("Asia/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Asia/Urumqi", "Asia/Urumqi"), + ("Asia/Ust-Nera", "Asia/Ust-Nera"), + ("Asia/Vientiane", "Asia/Vientiane"), + ("Asia/Vladivostok", "Asia/Vladivostok"), + ("Asia/Yakutsk", "Asia/Yakutsk"), + ("Asia/Yangon", "Asia/Yangon"), + ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), + ("Asia/Yerevan", "Asia/Yerevan"), + ("Atlantic/Azores", "Atlantic/Azores"), + ("Atlantic/Bermuda", "Atlantic/Bermuda"), + ("Atlantic/Canary", "Atlantic/Canary"), + ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), + ("Atlantic/Faroe", "Atlantic/Faroe"), + ("Atlantic/Madeira", "Atlantic/Madeira"), + ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), + ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), + ("Atlantic/St_Helena", "Atlantic/St_Helena"), + ("Atlantic/Stanley", "Atlantic/Stanley"), + ("Australia/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Sydney", "Australia/Sydney"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("Europe/Belgrade", "Europe/Belgrade"), + ("Europe/Berlin", "Europe/Berlin"), + ("Europe/Brussels", "Europe/Brussels"), + ("Europe/Bucharest", "Europe/Bucharest"), + ("Europe/Budapest", "Europe/Budapest"), + ("Europe/Chisinau", "Europe/Chisinau"), + ("Europe/Copenhagen", "Europe/Copenhagen"), + ("Europe/Dublin", "Europe/Dublin"), + ("Europe/Gibraltar", "Europe/Gibraltar"), + ("Europe/Helsinki", "Europe/Helsinki"), + ("Europe/Istanbul", "Europe/Istanbul"), + ("Europe/Kaliningrad", "Europe/Kaliningrad"), + ("Europe/Kirov", "Europe/Kirov"), + ("Europe/Kyiv", "Europe/Kyiv"), + ("Europe/Lisbon", "Europe/Lisbon"), + ("Europe/London", "Europe/London"), + ("Europe/Luxembourg", "Europe/Luxembourg"), + ("Europe/Madrid", "Europe/Madrid"), + ("Europe/Malta", "Europe/Malta"), + ("Europe/Minsk", "Europe/Minsk"), + ("Europe/Monaco", "Europe/Monaco"), + ("Europe/Moscow", "Europe/Moscow"), + ("Europe/Oslo", "Europe/Oslo"), + ("Europe/Paris", "Europe/Paris"), + ("Europe/Prague", "Europe/Prague"), + ("Europe/Riga", "Europe/Riga"), + ("Europe/Rome", "Europe/Rome"), + ("Europe/Samara", "Europe/Samara"), + ("Europe/Saratov", "Europe/Saratov"), + ("Europe/Simferopol", "Europe/Simferopol"), + ("Europe/Sofia", "Europe/Sofia"), + ("Europe/Stockholm", "Europe/Stockholm"), + ("Europe/Tallinn", "Europe/Tallinn"), + ("Europe/Tirane", "Europe/Tirane"), + ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), + ("Europe/Uzhgorod", "Europe/Uzhgorod"), + ("Europe/Vaduz", "Europe/Vaduz"), + ("Europe/Vienna", "Europe/Vienna"), + ("Europe/Vilnius", "Europe/Vilnius"), + ("Europe/Volgograd", "Europe/Volgograd"), + ("Europe/Warsaw", "Europe/Warsaw"), + ("Europe/Zaporozhye", "Europe/Zaporozhye"), + ("Europe/Zurich", "Europe/Zurich"), + ("Indian/Antananarivo", "Indian/Antananarivo"), + ("Indian/Chagos", "Indian/Chagos"), + ("Indian/Christmas", "Indian/Christmas"), + ("Indian/Cocos", "Indian/Cocos"), + ("Indian/Comoro", "Indian/Comoro"), + ("Indian/Kerguelen", "Indian/Kerguelen"), + ("Indian/Mahe", "Indian/Mahe"), + ("Indian/Maldives", "Indian/Maldives"), + ("Indian/Mauritius", "Indian/Mauritius"), + ("Indian/Mayotte", "Indian/Mayotte"), + ("Indian/Reunion", "Indian/Reunion"), + ("Pacific/Apia", "Pacific/Apia"), + ("Pacific/Auckland", "Pacific/Auckland"), + ("Pacific/Bougainville", "Pacific/Bougainville"), + ("Pacific/Chatham", "Pacific/Chatham"), + ("Pacific/Chuuk", "Pacific/Chuuk"), + ("Pacific/Easter", "Pacific/Easter"), + ("Pacific/Efate", "Pacific/Efate"), + ("Pacific/Fakaofo", "Pacific/Fakaofo"), + ("Pacific/Fiji", "Pacific/Fiji"), + ("Pacific/Funafuti", "Pacific/Funafuti"), + ("Pacific/Galapagos", "Pacific/Galapagos"), + ("Pacific/Gambier", "Pacific/Gambier"), + ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), + ("Pacific/Guam", "Pacific/Guam"), + ("Pacific/Honolulu", "Pacific/Honolulu"), + ("Pacific/Kanton", "Pacific/Kanton"), + ("Pacific/Kiritimati", "Pacific/Kiritimati"), + ("Pacific/Kosrae", "Pacific/Kosrae"), + ("Pacific/Kwajalein", "Pacific/Kwajalein"), + ("Pacific/Majuro", "Pacific/Majuro"), + ("Pacific/Marquesas", "Pacific/Marquesas"), + ("Pacific/Midway", "Pacific/Midway"), + ("Pacific/Nauru", "Pacific/Nauru"), + ("Pacific/Niue", "Pacific/Niue"), + ("Pacific/Norfolk", "Pacific/Norfolk"), + ("Pacific/Noumea", "Pacific/Noumea"), + ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), + ("Pacific/Palau", "Pacific/Palau"), + ("Pacific/Pitcairn", "Pacific/Pitcairn"), + ("Pacific/Pohnpei", "Pacific/Pohnpei"), + ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("UTC", "UTC"), + ], + default="UTC", + max_length=255, + ), + ), + ] diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index cc5241efa2..de0192769e 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -1,5 +1,4 @@ -# -*- coding: utf-8 -*- -# Copyright The IETF Trust 2007-2024, All Rights Reserved +# Copyright The IETF Trust 2007-2025, All Rights Reserved # old meeting models can be found in ../proceedings/models.py @@ -49,15 +48,20 @@ ) from ietf.utils.fields import MissingOkImageField -countries = list(pytz.country_names.items()) -countries.sort(key=lambda x: x[1]) +# Set up countries / timezones, including an empty choice for fields +EMPTY_CHOICE = ("", "-" * 9) +COUNTRIES = (EMPTY_CHOICE,) + tuple( + sorted(pytz.country_names.items(), key=lambda x: x[1]) +) -timezones = [] -for name in pytz.common_timezones: - tzfn = os.path.join(settings.TZDATA_ICS_PATH, name + ".ics") - if not os.path.islink(tzfn): - timezones.append((name, name)) -timezones.sort() +_tzdata_ics_path = Path(settings.TZDATA_ICS_PATH) +TIMEZONES = (EMPTY_CHOICE,) + tuple( + sorted( + (name, name) + for name in pytz.common_timezones + if name != "GMT" and not (_tzdata_ics_path / f"{name}.ics").is_symlink() + ) +) class Meeting(models.Model): @@ -72,11 +76,11 @@ class Meeting(models.Model): days = models.IntegerField(default=7, null=False, validators=[MinValueValidator(1)], help_text="The number of days the meeting lasts") city = models.CharField(blank=True, max_length=255) - country = models.CharField(blank=True, max_length=2, choices=countries) + country = models.CharField(blank=True, max_length=2, choices=COUNTRIES) # We can't derive time-zone from country, as there are some that have # more than one timezone, and the pytz module doesn't provide timezone # lookup information for all relevant city/country combinations. - time_zone = models.CharField(max_length=255, choices=timezones, default='UTC') + time_zone = models.CharField(max_length=255, choices=TIMEZONES, default='UTC') idsubmit_cutoff_day_offset_00 = models.IntegerField(blank=True, default=settings.IDSUBMIT_DEFAULT_CUTOFF_DAY_OFFSET_00, help_text = "The number of days before the meeting start date when the submission of -00 drafts will be closed.") @@ -530,7 +534,7 @@ class FloorPlan(models.Model): image = models.ImageField( storage=BlobShadowFileSystemStorage(kind="floorplan"), upload_to=floorplan_path, - blank=True, + blank=False, default=None, ) # diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index 15ae71d849..ebdda1a1fa 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -3341,7 +3341,7 @@ "customize_workflow": false, "default_parent": "", "default_tab": "ietf.group.views.group_about", - "default_used_roles": "[\n \"ad\",\n \"member\",\n \"comdir\",\n \"delegate\",\n \"execdir\",\n \"recman\",\n \"secr\",\n \"trac-editor\",\n \"trac-admin\",\n \"chair\"\n]", + "default_used_roles": "[\n \"ad\",\n \"member\",\n \"comdir\",\n \"delegate\",\n \"execdir\",\n \"recman\",\n \"secr\",\n \"chair\"\n]", "docman_roles": "[\n \"chair\"\n]", "groupman_authroles": "[\n \"Secretariat\"\n]", "groupman_roles": "[\n \"chair\",\n \"delegate\"\n]", @@ -5392,6 +5392,21 @@ "model": "mailtrigger.mailtrigger", "pk": "review_completed_opsdir_telechat" }, + { + "fields": { + "cc": [ + "ietf_last_call", + "review_doc_all_parties", + "review_doc_group_mail_list" + ], + "desc": "Recipients when a perfmetrdir Telechat review is completed", + "to": [ + "review_team_mail_list" + ] + }, + "model": "mailtrigger.mailtrigger", + "pk": "review_completed_perfmetrdir_telechat" + }, { "fields": { "cc": [ @@ -11330,6 +11345,17 @@ "model": "name.extresourcename", "pk": "mailing_list_archive" }, + { + "fields": { + "desc": "ORCID", + "name": "ORCID", + "order": 0, + "type": "url", + "used": true + }, + "model": "name.extresourcename", + "pk": "orcid" + }, { "fields": { "desc": "Related Implementations", @@ -13562,7 +13588,7 @@ "desc": "", "name": "Liaison CC Contact", "order": 9, - "used": true + "used": false }, "model": "name.rolename", "pk": "liaison_cc_contact" @@ -13572,11 +13598,21 @@ "desc": "", "name": "Liaison Contact", "order": 8, - "used": true + "used": false }, "model": "name.rolename", "pk": "liaison_contact" }, + { + "fields": { + "desc": "Coordinates liaison handling for the IAB", + "name": "Liaison Coordinator", + "order": 14, + "used": true + }, + "model": "name.rolename", + "pk": "liaison_coordinator" + }, { "fields": { "desc": "", @@ -13662,7 +13698,7 @@ "desc": "Assigned permission TRAC_ADMIN in datatracker-managed Trac Wiki instances", "name": "Trac Admin", "order": 0, - "used": true + "used": false }, "model": "name.rolename", "pk": "trac-admin" @@ -13672,7 +13708,7 @@ "desc": "Provides log-in permission to restricted Trac instances. Used by the generate_apache_perms management command, called from ../../scripts/Cron-runner", "name": "Trac Editor", "order": 0, - "used": true + "used": false }, "model": "name.rolename", "pk": "trac-editor" diff --git a/ietf/name/migrations/0018_alter_rolenames.py b/ietf/name/migrations/0018_alter_rolenames.py new file mode 100644 index 0000000000..f931de2e97 --- /dev/null +++ b/ietf/name/migrations/0018_alter_rolenames.py @@ -0,0 +1,36 @@ +# Copyright The IETF Trust 2025, All Rights Reserved# Generated by Django 4.2.21 on 2025-05-30 16:35 + +from django.db import migrations + + +def forward(apps, schema_editor): + RoleName = apps.get_model("name", "RoleName") + RoleName.objects.filter(slug__in=["liaison_contact", "liaison_cc_contact"]).update( + used=False + ) + RoleName.objects.get_or_create( + slug="liaison_coordinator", + defaults={ + "name": "Liaison Coordinator", + "desc": "Coordinates liaison handling for the IAB", + "order": 14, + }, + ) + RoleName.objects.filter(slug__contains="trac-").update(used=False) + + +def reverse(apps, schema_editor): + RoleName = apps.get_model("name", "RoleName") + RoleName.objects.filter(slug__in=["liaison_contact", "liaison_cc_contact"]).update( + used=True + ) + RoleName.objects.filter(slug="liaison_coordinator").delete() + # Intentionally not restoring trac-* RoleNames to used=True + + +class Migration(migrations.Migration): + dependencies = [ + ("name", "0017_populate_new_reg_names"), + ] + + operations = [migrations.RunPython(forward, reverse)] diff --git a/ietf/settings.py b/ietf/settings.py index 0b6b3745d2..5e33673611 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -61,6 +61,26 @@ 'django.contrib.auth.hashers.CryptPasswordHasher', ] + +PASSWORD_POLICY_MIN_LENGTH = 12 +PASSWORD_POLICY_ENFORCE_AT_LOGIN = False # should turn this on for prod + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + "OPTIONS": { + "min_length": PASSWORD_POLICY_MIN_LENGTH, + } + }, + { + "NAME": "ietf.ietfauth.password_validation.StrongPasswordValidator", + }, +] +# In dev environments, settings_local overrides the password validators. Save +# a handle to the original value so settings_test can restore it so tests match +# production. +ORIG_AUTH_PASSWORD_VALIDATORS = AUTH_PASSWORD_VALIDATORS + ALLOWED_HOSTS = [".ietf.org", ".ietf.org.", "209.208.19.216", "4.31.198.44", "127.0.0.1", "localhost", ] # Server name of the tools server diff --git a/ietf/settings_test.py b/ietf/settings_test.py index b5da9b833b..9a42e8b99d 100755 --- a/ietf/settings_test.py +++ b/ietf/settings_test.py @@ -14,7 +14,7 @@ import shutil import tempfile from ietf.settings import * # pyflakes:ignore -from ietf.settings import TEST_CODE_COVERAGE_CHECKER +from ietf.settings import TEST_CODE_COVERAGE_CHECKER, ORIG_AUTH_PASSWORD_VALIDATORS import debug # pyflakes:ignore debug.debug = True @@ -110,11 +110,8 @@ def tempdir_with_cleanup(**kwargs): }, } -# Configure storages for the blob store - use env settings if present. See the --no-manage-blobstore test option. -_blob_store_endpoint_url = os.environ.get("DATATRACKER_BLOB_STORE_ENDPOINT_URL", "http://blobstore:9000") -_blob_store_access_key = os.environ.get("DATATRACKER_BLOB_STORE_ACCESS_KEY", "minio_root") -_blob_store_secret_key = os.environ.get("DATATRACKER_BLOB_STORE_SECRET_KEY", "minio_pass") -_blob_store_bucket_prefix = os.environ.get("DATATRACKER_BLOB_STORE_BUCKET_PREFIX", "test-") -_blob_store_enable_profiling = ( - os.environ.get("DATATRACKER_BLOB_STORE_ENABLE_PROFILING", "false").lower() == "true" -) +# Restore AUTH_PASSWORD_VALIDATORS if they were reset in settings_local +try: + AUTH_PASSWORD_VALIDATORS = ORIG_AUTH_PASSWORD_VALIDATORS +except NameError: + pass diff --git a/ietf/static/js/password_strength.js b/ietf/static/js/password_strength.js index 4df5c14439..74a245fb5c 100644 --- a/ietf/static/js/password_strength.js +++ b/ietf/static/js/password_strength.js @@ -57,22 +57,40 @@ .parent() .parent() .find('.password_strength_offline_info'); + let password_improvement_hint = $(password_strength_info) + .find('.password_improvement_hint'); if ($(this) .val()) { var result = zxcvbn($(this) .val()); - - if (result.score < 3) { - password_strength_bar.removeClass('text-bg-success') - .addClass('text-bg-warning'); - password_strength_info.find('.badge') - .removeClass('d-none'); + const enforceStrength = !('disableStrengthEnforcement' in this.dataset); + const strongEnough = !enforceStrength || (result.score >= 3); + if (strongEnough) { + // Mark input as valid + this.setCustomValidity(''); } else { + // Mark input as invalid + this.setCustomValidity('This password does not meet complexity requirements'); + } + + if (this.checkValidity()) { password_strength_bar.removeClass('text-bg-warning') .addClass('text-bg-success'); password_strength_info.find('.badge') .addClass('d-none'); + this.classList.remove('is-invalid') + password_improvement_hint.addClass('d-none'); + password_improvement_hint.text('') + } else { + this.classList.add('is-invalid') + password_improvement_hint.text(this.validationMessage) + password_improvement_hint.removeClass('d-none'); + + password_strength_bar.removeClass('text-bg-success') + .addClass('text-bg-warning'); + password_strength_info.find('.badge') + .removeClass('d-none'); } password_strength_bar.width(((result.score + 1) / 5) * 100 + '%') @@ -152,23 +170,31 @@ .data('confirm-with'); if (confirm_with && confirm_with == password_field.attr('id')) { - if (confirm_value && password) { + if (password) { if (confirm_value === password) { $(confirm_field) .parent() .find('.password_strength_info') .addClass('d-none'); + confirm_field.setCustomValidity('') + confirm_field.classList.remove('is-invalid') } else { - $(confirm_field) - .parent() - .find('.password_strength_info') - .removeClass('d-none'); + if (confirm_value !== '') { + $(confirm_field) + .parent() + .find('.password_strength_info') + .removeClass('d-none'); + } + confirm_field.setCustomValidity('Does not match new password') + confirm_field.classList.add('is-invalid') } } else { $(confirm_field) .parent() .find('.password_strength_info') .addClass('d-none'); + confirm_field.setCustomValidity('') + confirm_field.classList.remove('is-invalid') } } }); diff --git a/ietf/sync/rfceditor.py b/ietf/sync/rfceditor.py index 889317cdcf..a3c6580452 100644 --- a/ietf/sync/rfceditor.py +++ b/ietf/sync/rfceditor.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2012-2020, All Rights Reserved +# Copyright The IETF Trust 2012-2025, All Rights Reserved # -*- coding: utf-8 -*- @@ -12,6 +12,7 @@ from xml.dom import pulldom, Node from django.conf import settings +from django.db import transaction from django.db.models import Subquery, OuterRef, F, Q from django.utils import timezone from django.utils.encoding import smart_bytes, force_str @@ -30,9 +31,9 @@ from ietf.utils.mail import send_mail_text from ietf.utils.timezone import datetime_from_date, RPC_TZINFO -#QUEUE_URL = "https://www.rfc-editor.org/queue2.xml" -#INDEX_URL = "https://www.rfc-editor.org/rfc/rfc-index.xml" -#POST_APPROVED_DRAFT_URL = "https://www.rfc-editor.org/sdev/jsonexp/jsonparser.php" +# QUEUE_URL = "https://www.rfc-editor.org/queue2.xml" +# INDEX_URL = "https://www.rfc-editor.org/rfc/rfc-index.xml" +# POST_APPROVED_DRAFT_URL = "https://www.rfc-editor.org/sdev/jsonexp/jsonparser.php" MIN_ERRATA_RESULTS = 5000 MIN_INDEX_RESULTS = 8000 @@ -427,7 +428,7 @@ def update_docs_from_rfc_index( pass # Logging below warning turns out to be unhelpful - there are many references # to such things in the index: - # * all april-1 RFCs have an internal name that looks like a draft name, but there + # * all april-1 RFCs have an internal name that looks like a draft name, but there # was never such a draft. More of these will exist in the future # * Several documents were created with out-of-band input to the RFC-editor, for a # variety of reasons. @@ -436,7 +437,7 @@ def update_docs_from_rfc_index( # If there is no draft to point to, don't point to one, even if there was an RPC # internal name in use (and in the RPC database). This will be a requirement on the # reimplementation of the creation of the rfc-index. - # + # # log(f"Warning: RFC index for {rfc_number} referred to unknown draft {draft_name}") # Find or create the RFC document @@ -466,7 +467,7 @@ def update_docs_from_rfc_index( if draft: doc.formal_languages.set(draft.formal_languages.all()) for author in draft.documentauthor_set.all(): - # Copy the author but point at the new doc. + # Copy the author but point at the new doc. # See https://docs.djangoproject.com/en/4.2/topics/db/queries/#copying-model-instances author.pk = None author.id = None @@ -707,12 +708,27 @@ def parse_relation_list(l): subseries_doc.docevent_set.create(type="sync_from_rfc_editor", by=system, desc=f"Added {doc.name} to {subseries_doc.name}") rfc_events.append(doc.docevent_set.create(type="sync_from_rfc_editor", by=system, desc=f"Added {doc.name} to {subseries_doc.name}")) - for subdoc in doc.related_that("contains"): - if subdoc.name not in also: - assert(not first_sync_creating_subseries) - subseries_doc.relateddocument_set.filter(target=subdoc).delete() - rfc_events.append(doc.docevent_set.create(type="sync_from_rfc_editor", by=system, desc=f"Removed {doc.name} from {subseries_doc.name}")) - subseries_doc.docevent_set.create(type="sync_from_rfc_editor", by=system, desc=f"Removed {doc.name} from {subseries_doc.name}") + # Delete subseries relations that are no longer current. Use a transaction + # so we are sure we iterate over the same relations that we delete! + with transaction.atomic(): + stale_subseries_relations = doc.relations_that("contains").exclude( + source__name__in=also + ) + for stale_relation in stale_subseries_relations: + stale_subseries_doc = stale_relation.source + rfc_events.append( + doc.docevent_set.create( + type="sync_from_rfc_editor", + by=system, + desc=f"Removed {doc.name} from {stale_subseries_doc.name}", + ) + ) + stale_subseries_doc.docevent_set.create( + type="sync_from_rfc_editor", + by=system, + desc=f"Removed {doc.name} from {stale_subseries_doc.name}", + ) + stale_subseries_relations.delete() doc_errata = errata.get(f"RFC{rfc_number}", []) all_rejected = doc_errata and all( @@ -754,9 +770,9 @@ def parse_relation_list(l): ) doc.save_with_history(rfc_events) yield rfc_number, rfc_changes, doc, rfc_published # yield changes to the RFC - + if first_sync_creating_subseries: - # First - create the known subseries documents that have ghosted. + # First - create the known subseries documents that have ghosted. # The RFC editor (as of 31 Oct 2023) claims these subseries docs do not exist. # The datatracker, on the other hand, will say that the series doc currently contains no RFCs. for name in ["fyi17", "std1", "bcp12", "bcp113", "bcp66"]: @@ -769,7 +785,6 @@ def parse_relation_list(l): subseries_slug = name[:3] subseries_doc.docevent_set.create(type=f"{subseries_slug}_history_marker", by=system, desc=f"No history of this {subseries_slug.upper()} document is currently available in the datatracker before this point") - RelatedDocument.objects.filter( Q(originaltargetaliasname__startswith="bcp") | Q(originaltargetaliasname__startswith="std") | diff --git a/ietf/sync/tasks.py b/ietf/sync/tasks.py index 53e23d7913..18ab4fe66e 100644 --- a/ietf/sync/tasks.py +++ b/ietf/sync/tasks.py @@ -11,6 +11,7 @@ from django.conf import settings from django.utils import timezone +from ietf.doc.models import DocEvent, RelatedDocument from ietf.sync import iana from ietf.sync import rfceditor from ietf.sync.rfceditor import MIN_QUEUE_RESULTS, parse_queue, update_drafts_from_queue @@ -180,3 +181,44 @@ def batched(l, n): for d in updated: log.log("Added history entry for %s" % d.display_name()) + +@shared_task +def fix_subseries_docevents_task(): + """Repairs DocEvents related to bugs around removing docs from subseries + + Removes bogus and repairs the date of non-bogus DocEvents + about removing RFCs from subseries + + This is designed to be a one-shot task that should be removed + after running it. It is intended to be safe if it runs more than once. + """ + log.log("Repairing DocEvents related to bugs around removing docs from subseries") + bogus_event_descs = [ + "Removed rfc8499 from bcp218", + "Removed rfc7042 from bcp184", + "Removed rfc9499 from bcp238", + "Removed rfc5033 from std74", + "Removed rfc3228 from bcp55", + "Removed rfc8109 from std85", + ] + DocEvent.objects.filter( + type="sync_from_rfc_editor", desc__in=bogus_event_descs + ).delete() + needs_moment_fix = [ + "Removed rfc8499 from bcp219", + "Removed rfc7042 from bcp141", + "Removed rfc5033 from bcp133", + "Removed rfc3228 from bcp57", + ] + # Assumptions (which have been manually verified): + # 1) each of the above RFCs is obsoleted by exactly one other RFC + # 2) each of the obsoleting RFCs has exactly one published_rfc docevent + for desc in needs_moment_fix: + obsoleted_rfc_name = desc.split(" ")[1] + obsoleting_rfc = RelatedDocument.objects.get( + relationship_id="obs", target__name=obsoleted_rfc_name + ).source + obsoleting_time = obsoleting_rfc.docevent_set.get(type="published_rfc").time + DocEvent.objects.filter(type="sync_from_rfc_editor", desc=desc).update( + time=obsoleting_time + ) diff --git a/ietf/sync/tests.py b/ietf/sync/tests.py index b0cdf863f0..14d65de0b2 100644 --- a/ietf/sync/tests.py +++ b/ietf/sync/tests.py @@ -20,7 +20,13 @@ import debug # pyflakes:ignore from ietf.api.views import EmailIngestionError -from ietf.doc.factories import WgDraftFactory, RfcFactory, DocumentAuthorFactory, DocEventFactory +from ietf.doc.factories import ( + WgDraftFactory, + RfcFactory, + DocumentAuthorFactory, + DocEventFactory, + BcpFactory, +) from ietf.doc.models import Document, DocEvent, DeletedEvent, DocTagName, RelatedDocument, State, StateDocEvent from ietf.doc.utils import add_state_change_event from ietf.group.factories import GroupFactory @@ -508,6 +514,120 @@ def test_rfc_index(self): changed = list(rfceditor.update_docs_from_rfc_index(data, errata, today - datetime.timedelta(days=30))) self.assertEqual(len(changed), 0) + def test_rfc_index_subseries_replacement(self): + today = date_today() + author = PersonFactory(name="Some Bozo") + + # Start with two BCPs, each containing an rfc + rfc1, rfc2, rfc3 = RfcFactory.create_batch(3, authors=[author]) + bcp1 = BcpFactory(contains=[rfc1]) + bcp2 = BcpFactory(contains=[rfc2]) + + def _nameify(doc): + """Convert a name like 'rfc1' to 'RFC0001""" + return f"{doc.name[:3].upper()}{int(doc.name[3:]):04d}" + + # RFC index that replaces rfc2 with rfc3 in bcp2 + index_xml = f""" + + + {_nameify(bcp1)} + + {_nameify(rfc1)} + + + + {_nameify(bcp2)} + + {_nameify(rfc3)} + + + + {_nameify(rfc1)} + {rfc1.title} + + Some Bozo + + + {today.strftime('%B')} + {today.strftime('%Y')} + + + ASCII + + 42 + + test + +

This is some interesting text.

+ + {_nameify(bcp1)} + + PROPOSED STANDARD + PROPOSED STANDARD + IETF +
+ + {_nameify(rfc2)} + {rfc2.title} + + Some Bozo + + + {today.strftime('%B')} + {today.strftime('%Y')} + + + ASCII + + 42 + + test + +

This is some interesting text.

+ PROPOSED STANDARD + PROPOSED STANDARD + IETF +
+ + {_nameify(rfc3)} + {rfc3.title} + + Some Bozo + + + {today.strftime('%B')} + {today.strftime('%Y')} + + + ASCII + + 42 + + test + +

This is some interesting text.

+ + {_nameify(bcp2)} + + PROPOSED STANDARD + PROPOSED STANDARD + IETF +
+
""" + data = rfceditor.parse_index(io.StringIO(index_xml)) # parse index + self.assertEqual(len(data), 3) # check that we parsed 3 RFCs + # Process the data by consuming the generator + for _ in rfceditor.update_docs_from_rfc_index(data, []): + pass + # Confirm that the expected changes were made + self.assertCountEqual(rfc1.related_that("contains"), [bcp1]) + self.assertCountEqual(rfc2.related_that("contains"), []) + self.assertCountEqual(rfc3.related_that("contains"), [bcp2]) + def _generate_rfc_queue_xml(self, draft, state, auth48_url=None): """Generate an RFC queue xml string for a draft""" t = ''' diff --git a/ietf/templates/liaisons/detail.html b/ietf/templates/liaisons/detail.html index 4bf5b4d11b..d46c8d1c98 100644 --- a/ietf/templates/liaisons/detail.html +++ b/ietf/templates/liaisons/detail.html @@ -32,7 +32,7 @@

{% if liaison.from_contact %} From Contact - {% person_link liaison.from_contact.person %} + {{ liaison.from_contact }} {% endif %} diff --git a/ietf/templates/liaisons/liaison_mail.txt b/ietf/templates/liaisons/liaison_mail.txt index c92c68ff76..6d6a07d7ef 100644 --- a/ietf/templates/liaisons/liaison_mail.txt +++ b/ietf/templates/liaisons/liaison_mail.txt @@ -2,7 +2,7 @@ Submission Date: {{ liaison.submitted|date:"Y-m-d" }} URL of the IETF Web page: {{ liaison.get_absolute_url }} {% if liaison.deadline %}Please reply by {{ liaison.deadline }}{% endif %} -From: {% if liaison.from_contact %}{{ liaison.from_contact.formatted_email }}{% endif %} +From: {% if liaison.from_contact %}{{ liaison.from_contact }}{% endif %} To: {{ liaison.to_contacts }} Cc: {{ liaison.cc_contacts }} Response Contacts: {{ liaison.response_contacts }} diff --git a/ietf/templates/registration/change_password.html b/ietf/templates/registration/change_password.html index 21c102bd0a..58bc2d2587 100644 --- a/ietf/templates/registration/change_password.html +++ b/ietf/templates/registration/change_password.html @@ -34,11 +34,14 @@

Change password

- Online attack: This password form uses the + Password strength requirements: + You must choose a password at least 12 characters long that scores at least a 3 according to the zxcvbn - password strength estimator to give an indication of password strength. - The crack time estimate given above assume online attack without rate - limiting, at a rate of 10 attempts per second. + password strength estimator. A warning will appear if your password does not meet this standard. +
+ Online attack: + The crack time estimate given above assumes an online attack at a rate of 10 attempts per second. + It is only a very rough guideline.
Offline cracking: The datatracker currently uses the {{ hasher.algorithm }} diff --git a/ietf/utils/tests.py b/ietf/utils/tests.py index 2dd861cd11..872aa366b9 100644 --- a/ietf/utils/tests.py +++ b/ietf/utils/tests.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2014-2020, All Rights Reserved +# Copyright The IETF Trust 2014-2025, All Rights Reserved # -*- coding: utf-8 -*- @@ -741,6 +741,23 @@ def test_render_author_name(self): "J. Q.", ) + @patch("ietf.utils.xmldraft.XMLDraft.__init__", return_value=None) + def test_get_title(self, mock_init): + xmldraft = XMLDraft("fake") + self.assertTrue(mock_init.called) + # Stub XML that does not have a front/title element + xmldraft.xmlroot = lxml.etree.XML( + "" # no title + ) + self.assertEqual(xmldraft.get_title(), "") + + # Stub XML that has a front/title element + xmldraft.xmlroot = lxml.etree.XML( + "This Is the Title" + ) + self.assertEqual(xmldraft.get_title(), "This Is the Title") + + def test_capture_xml2rfc_output(self): """capture_xml2rfc_output reroutes and captures xml2rfc logs""" orig_write_out = xml2rfc_log.write_out diff --git a/ietf/utils/validators.py b/ietf/utils/validators.py index 8fe989df99..92a20f5a26 100644 --- a/ietf/utils/validators.py +++ b/ietf/utils/validators.py @@ -4,6 +4,8 @@ import os import re +from email.utils import parseaddr + from pyquery import PyQuery from urllib.parse import urlparse, urlsplit, urlunsplit @@ -11,7 +13,13 @@ from django.apps import apps from django.conf import settings from django.core.exceptions import ObjectDoesNotExist, ValidationError -from django.core.validators import RegexValidator, URLValidator, EmailValidator, BaseValidator +from django.core.validators import ( + RegexValidator, + URLValidator, + BaseValidator, + validate_email, + ProhibitNullCharactersValidator, +) from django.template.defaultfilters import filesizeformat from django.utils.deconstruct import deconstructible from django.utils.ipv6 import is_valid_ipv6_address @@ -136,8 +144,17 @@ def validate_no_html_frame(file): # instantiations of sub-validiators used by the external_resource validator validate_url = URLValidator() -validate_http_url = URLValidator(schemes=['http','https']) -validate_email = EmailValidator() +validate_http_url = URLValidator(schemes=["http", "https"]) +validate_no_nulls = ProhibitNullCharactersValidator() + + +def validate_mailbox_address(s): + """Validate an RFC 5322 'mailbox' (e.g., "Some Person" )""" + # parseaddr() returns ("", "") on err; validate_email() will reject that for us + name, addr = parseaddr(s) + validate_no_nulls(name) # could be stricter... + validate_email(addr) + def validate_ipv6_address(value): if not is_valid_ipv6_address(value): diff --git a/ietf/utils/xmldraft.py b/ietf/utils/xmldraft.py index 3ac9a269c7..7ef6605c78 100644 --- a/ietf/utils/xmldraft.py +++ b/ietf/utils/xmldraft.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2022, All Rights Reserved +# Copyright The IETF Trust 2022-2025, All Rights Reserved # -*- coding: utf-8 -*- import datetime import io @@ -147,7 +147,8 @@ def parse_docname(xmlroot): return revmatch.group('filename'), revmatch.group('rev') def get_title(self): - return self.xmlroot.findtext('front/title').strip() + title_text = self.xmlroot.findtext('front/title') + return "" if title_text is None else title_text.strip() @staticmethod def parse_creation_date(date_elt): diff --git a/k8s/rabbitmq.yaml b/k8s/rabbitmq.yaml index 0c8f0705b5..780a399239 100644 --- a/k8s/rabbitmq.yaml +++ b/k8s/rabbitmq.yaml @@ -52,17 +52,17 @@ spec: key: CELERY_PASSWORD livenessProbe: exec: - command: ["rabbitmq-diagnostics", "-q", "ping"] + command: ["rabbitmq-diagnostics", "-q", "ping", "-t", "30"] periodSeconds: 30 - timeoutSeconds: 5 + timeoutSeconds: 35 # slightly longer than ping "-t" option startupProbe: initialDelaySeconds: 15 periodSeconds: 5 - timeoutSeconds: 5 + timeoutSeconds: 35 # slightly longer than ping "-t" option successThreshold: 1 failureThreshold: 60 exec: - command: ["rabbitmq-diagnostics", "-q", "ping"] + command: ["rabbitmq-diagnostics", "-q", "ping", "-t", "30"] securityContext: allowPrivilegeEscalation: false capabilities: diff --git a/k8s/settings_local.py b/k8s/settings_local.py index 074888728f..482a4b110a 100644 --- a/k8s/settings_local.py +++ b/k8s/settings_local.py @@ -165,6 +165,21 @@ def _multiline_to_list(s): raise RuntimeError("DATATRACKER_REGISTRATION_API_KEY must be set") STATS_REGISTRATION_ATTENDEES_JSON_URL = f"https://registration.ietf.org/{{number}}/attendees/?apikey={_registration_api_key}" +# Registration Participants API config - key must be set, but the URL can be left +# to the default in settings.py +_registration_participants_api_key = os.environ.get( + "DATATRACKER_REGISTRATION_PARTICIPANTS_API_KEY", None +) +if _registration_participants_api_key is None: + raise RuntimeError("DATATRACKER_REGISTRATION_PARTICIPANTS_API_KEY must be set") +REGISTRATION_PARTICIPANTS_API_KEY = _registration_participants_api_key + +_registration_participants_api_url = os.environ.get( + "DATATRACKER_REGISTRATION_PARTICIPANTS_API_URL", None +) +if _registration_participants_api_url is not None: + REGISTRATION_PARTICIPANTS_API_URL = _registration_participants_api_url + # FIRST_CUTOFF_DAYS = 12 # SECOND_CUTOFF_DAYS = 12 # SUBMISSION_CUTOFF_DAYS = 26 @@ -391,3 +406,8 @@ def _multiline_to_list(s): "EXCLUDE_BUCKETS": ["staging"], "VERBOSE_LOGGING": _blobdb_replication_verbose_logging, } + +# Optionally disable password strength enforcement at login (on by default) +PASSWORD_POLICY_ENFORCE_AT_LOGIN = ( + os.environ.get("DATATRACKER_ENFORCE_PW_POLICY", "true").lower() != "false" +) diff --git a/requirements.txt b/requirements.txt index eb72600fe3..4eb573ce36 100644 --- a/requirements.txt +++ b/requirements.txt @@ -79,8 +79,10 @@ selenium>=4.0 tblib>=1.7.0 # So that the django test runner provides tracebacks tlds>=2022042700 # Used to teach bleach about which TLDs currently exist tqdm>=4.64.0 +types-zxcvbn~=4.5.0.20250223 # match zxcvbn version Unidecode>=1.3.4 urllib3>=1.26,<2 weasyprint>=64.1 xml2rfc>=3.23.0 xym>=0.6,<1.0 +zxcvbn>=4.5.0