From 26499a7a6cb36acab98ff1a22b8d7e2eeb63e1d9 Mon Sep 17 00:00:00 2001 From: Paul McLanahan Date: Thu, 22 May 2025 15:42:34 -0400 Subject: [PATCH 1/3] Add release notes administration to admin - first working version --- bin/run-db-update.sh | 1 - requirements/dev.txt | 164 ++++----- requirements/docs.txt | 6 +- requirements/prod.in | 2 + requirements/prod.txt | 37 +- .../admin/templates/admin/base_site.html | 5 + .../templates/firefox/releases/notes.html | 4 +- .../firefox/releases/system_requirements.html | 2 +- springfield/releasenotes/admin.py | 165 +++++++++ .../releasenotes/management/__init__.py | 3 - .../management/commands/__init__.py | 3 - .../commands/update_release_notes.py | 40 --- springfield/releasenotes/middleware.py | 14 + .../migrations/0003_country_release_note.py | 74 ++++ .../0004_data_bootstrap_countries.py | 280 +++++++++++++++ .../releasenotes/migrations/__init__.py | 3 - springfield/releasenotes/models.py | 331 ++++++++++++++++-- springfield/releasenotes/serializers.py | 40 +++ .../templates/admin/base_site.html | 6 + .../admin/releasenotes/note/change_form.html | 20 ++ .../releasenotes/release/change_form.html | 33 ++ .../tests/releases/.latest-update-etag | 1 - .../tests/releases/firefox-24.5.0-esr.json | 27 -- .../tests/releases/firefox-45.0esr-esr.json | 47 --- .../releases/firefox-55.0a1-nightly.json | 205 ----------- .../tests/releases/firefox-56.0-release.json | 9 - .../releases/firefox-56.0a1-nightly.json | 37 -- .../releases/firefox-57.0a1-nightly.json | 77 ---- .../releases/firefox-58.0a1-nightly.json | 9 - .../releases/firefox-copy-56.0-release.json | 9 - .../firefox-for-android-56.0-release.json | 9 - .../firefox-for-android-56.0.1-release.json | 9 - .../firefox-for-android-56.0.2-release.json | 9 - .../firefox-for-android-56.0.3-release.json | 9 - .../firefox-for-android-58.0a1-nightly.json | 9 - springfield/releasenotes/urls.py | 19 + springfield/releasenotes/utils.py | 79 +++-- springfield/releasenotes/views.py | 62 +++- springfield/rna/__init__.py | 0 springfield/rna/tests/__init__.py | 0 springfield/rna/tests/test_models.py | 137 ++++++++ springfield/rna/tests/test_views.py | 127 +++++++ springfield/rna/views.py | 67 ++++ springfield/settings/base.py | 27 +- springfield/urls.py | 3 + 45 files changed, 1542 insertions(+), 678 deletions(-) create mode 100644 springfield/releasenotes/admin.py delete mode 100644 springfield/releasenotes/management/__init__.py delete mode 100644 springfield/releasenotes/management/commands/__init__.py delete mode 100644 springfield/releasenotes/management/commands/update_release_notes.py create mode 100644 springfield/releasenotes/middleware.py create mode 100644 springfield/releasenotes/migrations/0003_country_release_note.py create mode 100644 springfield/releasenotes/migrations/0004_data_bootstrap_countries.py create mode 100644 springfield/releasenotes/serializers.py create mode 100644 springfield/releasenotes/templates/admin/base_site.html create mode 100644 springfield/releasenotes/templates/admin/releasenotes/note/change_form.html create mode 100644 springfield/releasenotes/templates/admin/releasenotes/release/change_form.html delete mode 100644 springfield/releasenotes/tests/releases/.latest-update-etag delete mode 100644 springfield/releasenotes/tests/releases/firefox-24.5.0-esr.json delete mode 100644 springfield/releasenotes/tests/releases/firefox-45.0esr-esr.json delete mode 100644 springfield/releasenotes/tests/releases/firefox-55.0a1-nightly.json delete mode 100644 springfield/releasenotes/tests/releases/firefox-56.0-release.json delete mode 100644 springfield/releasenotes/tests/releases/firefox-56.0a1-nightly.json delete mode 100644 springfield/releasenotes/tests/releases/firefox-57.0a1-nightly.json delete mode 100644 springfield/releasenotes/tests/releases/firefox-58.0a1-nightly.json delete mode 100644 springfield/releasenotes/tests/releases/firefox-copy-56.0-release.json delete mode 100644 springfield/releasenotes/tests/releases/firefox-for-android-56.0-release.json delete mode 100644 springfield/releasenotes/tests/releases/firefox-for-android-56.0.1-release.json delete mode 100644 springfield/releasenotes/tests/releases/firefox-for-android-56.0.2-release.json delete mode 100644 springfield/releasenotes/tests/releases/firefox-for-android-56.0.3-release.json delete mode 100644 springfield/releasenotes/tests/releases/firefox-for-android-58.0a1-nightly.json create mode 100644 springfield/releasenotes/urls.py create mode 100644 springfield/rna/__init__.py create mode 100644 springfield/rna/tests/__init__.py create mode 100644 springfield/rna/tests/test_models.py create mode 100644 springfield/rna/tests/test_views.py create mode 100644 springfield/rna/views.py diff --git a/bin/run-db-update.sh b/bin/run-db-update.sh index 26b9c3902..c319aaa91 100755 --- a/bin/run-db-update.sh +++ b/bin/run-db-update.sh @@ -33,7 +33,6 @@ failure_detected=false # Please ensure all new command calls are suffixed with || failure_detected=true python manage.py update_product_details_files || failure_detected=true -python manage.py update_release_notes --quiet || failure_detected=true python manage.py update_newsletter_data --quiet || failure_detected=true # if [[ "$AUTH" == true ]]; then diff --git a/requirements/dev.txt b/requirements/dev.txt index ee07be659..9ef37acd2 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -61,9 +61,9 @@ boto3==1.38.0 \ --hash=sha256:8b6544eca17e31d1bfd538e5d152b96a68d6c92950352a0cd9679f89d217d53a \ --hash=sha256:96898facb164b47859d40a4271007824a0a791c3811a7079ce52459d753d4474 # via -r requirements/prod.txt -botocore==1.38.17 \ - --hash=sha256:ec75cf02fbd3dbec18187085ce387761eab16afdccfd0774fd168db3689c6cb6 \ - --hash=sha256:f2db4c4bdcfbc41d78bfe73b9affe7d217c7840f8ce120cff815536969418b18 +botocore==1.38.21 \ + --hash=sha256:08d5e9c00e5cc9e0ae0e60570846011789dc7f1d4ea094b3f3e3f3ae1ff2063a \ + --hash=sha256:567b4d338114174d0b41857002a4b1e8efb68f1654ed9f3ec6c34ebdef5e9eaf # via # -r requirements/prod.txt # boto3 @@ -266,9 +266,9 @@ cl-ext-lang==0.1.0 \ --hash=sha256:662fcccc1ff77063dd9d6bad770c26d1345cf4cf01e679eab136e80f3ec2935c \ --hash=sha256:90b94aaee4afecbf2a9be3cd65656940f38edea3ddde14b93a02a9e1ee5d1f72 # via -r requirements/dev.in -click==8.2.0 \ - --hash=sha256:6b303f0b2aa85f1cb4e5303078fadcbcd4e476f114fab9b5007005711839325c \ - --hash=sha256:f5452aeddd9988eefa20f90f05ab66f17fce1ee2a36907fd30b05bbb5953814d +click==8.2.1 \ + --hash=sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202 \ + --hash=sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b # via # -r requirements/prod.txt # granian @@ -287,70 +287,70 @@ contextlib2==21.6.0 \ --hash=sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f \ --hash=sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869 # via -r requirements/prod.txt -coverage[toml]==7.8.0 \ - --hash=sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f \ - --hash=sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3 \ - --hash=sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05 \ - --hash=sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25 \ - --hash=sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe \ - --hash=sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257 \ - --hash=sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78 \ - --hash=sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada \ - --hash=sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64 \ - --hash=sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6 \ - --hash=sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28 \ - --hash=sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067 \ - --hash=sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733 \ - --hash=sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676 \ - --hash=sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23 \ - --hash=sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008 \ - --hash=sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd \ - --hash=sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3 \ - --hash=sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82 \ - --hash=sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545 \ - --hash=sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00 \ - --hash=sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47 \ - --hash=sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501 \ - --hash=sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d \ - --hash=sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814 \ - --hash=sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd \ - --hash=sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a \ - --hash=sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318 \ - --hash=sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3 \ - --hash=sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c \ - --hash=sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42 \ - --hash=sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a \ - --hash=sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6 \ - --hash=sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a \ - --hash=sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7 \ - --hash=sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487 \ - --hash=sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4 \ - --hash=sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2 \ - --hash=sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9 \ - --hash=sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd \ - --hash=sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73 \ - --hash=sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc \ - --hash=sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f \ - --hash=sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea \ - --hash=sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899 \ - --hash=sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a \ - --hash=sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543 \ - --hash=sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1 \ - --hash=sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7 \ - --hash=sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d \ - --hash=sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502 \ - --hash=sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b \ - --hash=sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040 \ - --hash=sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c \ - --hash=sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27 \ - --hash=sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c \ - --hash=sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d \ - --hash=sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4 \ - --hash=sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe \ - --hash=sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323 \ - --hash=sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883 \ - --hash=sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f \ - --hash=sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f +coverage[toml]==7.8.1 \ + --hash=sha256:0034ceec8e91fdaf77350901cc48f47efd00f23c220a3f9fc1187774ddf307cb \ + --hash=sha256:061a3bf679dc38fe34d3822f10a9977d548de86b440010beb1e3b44ba93d20f7 \ + --hash=sha256:07fff2f2ce465fae27447432d39ce733476fbf8478de51fb4034c201e0c5da6d \ + --hash=sha256:11e5ea0acd8cc5d23030c34dfb2eb6638ad886328df18cc69f8eefab73d1ece5 \ + --hash=sha256:12950b6373dc9dfe1ce22a8506ec29c82bfc5b38146ced0a222f38cf5d99a56d \ + --hash=sha256:173e16969f990688aae4b4487717c44330bc57fd8b61a6216ce8eeb827eb5c0d \ + --hash=sha256:1cc6bebc15c3b275174c66cf4e1c949a94c5c2a3edaa2f193a1225548c52c771 \ + --hash=sha256:21645788c5c2afa3df2d4b607638d86207b84cb495503b71e80e16b4c6b44e80 \ + --hash=sha256:2d8f844e837374a9497e11722d9eb9dfeb33b1b5d31136786c39a4c1a3073c6d \ + --hash=sha256:35dd5d405a1d378c39f3f30f628a25b0b99f1b8e5bdd78275df2e7b0404892d7 \ + --hash=sha256:3763b9a4bc128f72da5dcfd7fcc7c7d6644ed28e8f2db473ce1ef0dd37a43fa9 \ + --hash=sha256:3bd8e3753257e95e94f38c058627aba1581d51f674e3badf226283b2bdb8f8ca \ + --hash=sha256:41d142eefbc0bb3be160a77b2c0fbec76f345387676265052e224eb6c67b7af3 \ + --hash=sha256:452f3831c64f5f50260e18a89e613594590d6ceac5206a9b7d76ba43586b01b3 \ + --hash=sha256:4fc4f7cff2495d6d112353c33a439230a6de0b7cd0c2578f1e8d75326f63d783 \ + --hash=sha256:62a13b372b65fa6e11685df9ca924bed23bab1d0f277f9b67be7536f253aaf17 \ + --hash=sha256:6ce2606a171f9cf7c15a77ca61f979ffc0e0d92cd2fb18767cead58c1d19f58e \ + --hash=sha256:6f24a1e2c373a77afae21bc512466a91e31251685c271c5309ee3e557f6e3e03 \ + --hash=sha256:76a4e1d62505a21971968be61ae17cbdc5e0c483265a37f7ddbbc050f9c0b8ec \ + --hash=sha256:82db9344a07dd9106796b9fe8805425633146a7ea7fed5ed07c65a64d0bb79e1 \ + --hash=sha256:87284f272746e31919302ab6211b16b41135109822c498f6e7b40a2f828e7836 \ + --hash=sha256:87b86a87f8de2e1bd0bcd45faf1b1edf54f988c8857157300e0336efcfb8ede6 \ + --hash=sha256:8bcfafb2809cd01be8ffe5f962e01b0fbe4cc1d74513434c52ff2dd05b86d492 \ + --hash=sha256:9296df6a33b8539cd753765eb5b47308602263a14b124a099cbcf5f770d7cf90 \ + --hash=sha256:958b513e23286178b513a6b4d975fe9e7cddbcea6e5ebe8d836e4ef067577154 \ + --hash=sha256:9772c9e266b2ca4999180c12b90c8efb4c5c9ad3e55f301d78bc579af6467ad9 \ + --hash=sha256:9b31756ea647b6ef53190f6b708ad0c4c2ea879bc17799ba5b0699eee59ecf7b \ + --hash=sha256:9cd54a762667c32112df5d6f059c5d61fa532ee06460948cc5bcbf60c502f5c9 \ + --hash=sha256:9dabc70012fd7b58a8040a7bc1b5f71fd0e62e2138aefdd8367d3d24bf82c349 \ + --hash=sha256:adafe9d71a940927dd3ad8d487f521f11277f133568b7da622666ebd08923191 \ + --hash=sha256:ae5e557aa92565d72f6d3196e878e7cbd6a6380e02a15eafe0af781bd767c10d \ + --hash=sha256:af6b8cdf0857fd4e6460dd6639c37c3f82163127f6112c1942b5e6a52a477676 \ + --hash=sha256:b37729ba34c116a3b2b6fb99df5c37a4ca40e96f430070488fd7a1077ad44907 \ + --hash=sha256:b6424c716f4c38ff8f62b602e6b94cde478dadda542a1cb3fe2fe2520cc2aae3 \ + --hash=sha256:b8f105631835fdf191c971c4da93d27e732e028d73ecaa1a88f458d497d026cf \ + --hash=sha256:be2b1a455b3ecfee20638289bb091a95216887d44924a41c28a601efac0916e8 \ + --hash=sha256:c04a7903644ccea8fa07c3e76db43ca31c8d453f93c5c94c0f9b82efca225543 \ + --hash=sha256:c0d6290a466a6f3fadf6add2dd4ec11deba4e1a6e3db2dd284edd497aadf802f \ + --hash=sha256:c5757a7b25fe48040fa120ba6597f5f885b01e323e0d13fe21ff95a70c0f76b7 \ + --hash=sha256:ccad4e29ac1b6f75bfeedb2cac4860fe5bd9e0a2f04c3e3218f661fa389ab101 \ + --hash=sha256:cd21de85aa0e247b79c6c41f8b5541b54285550f2da6a9448d82b53234d3611b \ + --hash=sha256:ce4553a573edb363d5db12be1c044826878bec039159d6d4eafe826ef773396d \ + --hash=sha256:d074380f587360d2500f3b065232c67ae248aaf739267807adbcd29b88bdf864 \ + --hash=sha256:d41d4da5f2871b1782c6b74948d2d37aac3a5b39b43a6ba31d736b97a02ae1f1 \ + --hash=sha256:d5102e17b81158de17d4b5bc363fcffd15231a38ef3f50b8e6fa01f0c6911194 \ + --hash=sha256:d52d79dfd3b410b153b6d65b0e3afe834eca2b969377f55ad73c67156d35af0d \ + --hash=sha256:d591f2ddad432b794f77dc1e94334a80015a3fc7fa07fd6aed8f40362083be5b \ + --hash=sha256:d616b5a543c7d4deffa25eb8d8ae3d0d95097f08ac8b131600bb7fbf967ea0e2 \ + --hash=sha256:d7af3990490982fbd2437156c69edbe82b7edf99bc60302cceeeaf79afb886b8 \ + --hash=sha256:d8a6c35afd5b912101fabf42975d92d750cfce33c571508a82ff334a133c40d5 \ + --hash=sha256:db181a1896e0bad75b3bf4916c49fd3cf6751f9cc203fe0e0ecbee1fc43590fa \ + --hash=sha256:dd5c305faa2e69334a53061b3168987847dadc2449bab95735242a9bde92fde8 \ + --hash=sha256:e1f8e96455907496b3e4ea16f63bb578da31e17d2805278b193525e7714f17f2 \ + --hash=sha256:e233a56bbf99e4cb134c4f8e63b16c77714e3987daf2c5aa10c3ba8c4232d730 \ + --hash=sha256:e3f65da9701648d226b6b24ded3e2528b72075e48d7540968cd857c3bd4c5321 \ + --hash=sha256:e4e893c7f7fb12271a667d5c1876710fae06d7580343afdb5f3fc4488b73209e \ + --hash=sha256:e54b80885b0e61d346accc5709daf8762471a452345521cc9281604a907162c2 \ + --hash=sha256:e93f36a5c9d995f40e9c4cd9bbabd83fd78705792fa250980256c93accd07bb6 \ + --hash=sha256:ebdf212e1ed85af63fa1a76d556c0a3c7b34348ffba6e145a64b15f003ad0a2b \ + --hash=sha256:f7a95b0dce364535a63fde0ec1b1ca36400037175d3b62ce04d85dbca5e33832 \ + --hash=sha256:f82c1a1c1897d2293cb6c50f20fe8a9ea2add1a228eff479380917a1fe7bbb68 \ + --hash=sha256:fe4877c24711458f7990392181be30166cc3ae72158036ecb48a73c30c99fb6f \ + --hash=sha256:ff619c58322d9d6df0a859dc76c3532d7bdbc125cb040f7cd642141446b4f654 # via pytest-cov cryptography==44.0.3 \ --hash=sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259 \ @@ -485,6 +485,7 @@ django==5.2.1 \ # django-memoize # django-modelcluster # django-mozilla-product-details + # django-pagedown # django-permissionedforms # django-rq # django-rq-email-backend @@ -551,6 +552,10 @@ django-mozilla-product-details==1.0.3 \ --hash=sha256:1d139ba01f4484f3bb43b72864ce33f249835405449e0dc940217cfa42ce5b46 \ --hash=sha256:a4aba6a68b296dffe8c1afb95d236cdbd402bd855cd49eef4d8a1a610105fd36 # via -r requirements/prod.txt +django-pagedown==2.2.1 \ + --hash=sha256:2e6373a3f1ec1886ec0bdd16a0db1d5f5d14437a0fdddefa7fba905cef50d6e8 \ + --hash=sha256:9b4204d65334d0add1d3daf2533d91f96b62a11fe89c5e2ca93a31fbd4b38aeb + # via -r requirements/prod.txt django-permissionedforms==0.1 \ --hash=sha256:4340bb20c4477fffb13b4cc5cccf9f1b1010b64f79956c291c72d2ad2ed243f8 \ --hash=sha256:d341a961a27cc77fde8cc42141c6ab55cc1f0cb886963cc2d6967b9674fa47d6 @@ -607,9 +612,9 @@ django-watchman==1.3.0 \ --hash=sha256:33b5fc734d689b83cb96fc17beda624ae2955f4cede0856897d990c363eac962 \ --hash=sha256:5f04300bd7fbdd63b8a883b2730ed1e4d9b0f9991133b33a1281134b81f466eb # via -r requirements/prod.txt -djangorestframework==3.16.0 \ - --hash=sha256:bea7e9f6b96a8584c5224bfb2e4348dfb3f8b5e34edbecb98da258e892089361 \ - --hash=sha256:f022ff46613584de994c0c6a4aebbace5fd700555fbe9d33b865ebf173eba6c9 +djangorestframework==3.15.2 \ + --hash=sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20 \ + --hash=sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad # via # -r requirements/prod.txt # wagtail @@ -676,9 +681,9 @@ google-api-core==2.24.2 \ # -r requirements/prod.txt # google-cloud-core # google-cloud-storage -google-auth==2.40.1 \ - --hash=sha256:58f0e8416a9814c1d86c9b7f6acf6816b51aba167b2c76821965271bac275540 \ - --hash=sha256:ed4cae4f5c46b41bae1d19c036e06f6c371926e97b19e816fc854eff811974ee +google-auth==2.40.2 \ + --hash=sha256:a33cde547a2134273226fa4b853883559947ebe9207521f7afc707efbf690f58 \ + --hash=sha256:f7e568d42eedfded58734f6a60c58321896a621f7c116c411550a4b4a13da90b # via # -r requirements/prod.txt # google-api-core @@ -1381,6 +1386,7 @@ pillow==11.2.1 \ --hash=sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b # via # -r requirements/prod.txt + # django-pagedown # pillow-heif # wagtail pillow-heif==0.21.0 \ @@ -1880,9 +1886,9 @@ sentry-sdk==2.26.1 \ --hash=sha256:759e019c41551a21519a95e6cef6d91fb4af1054761923dadaee2e6eca9c02c7 \ --hash=sha256:e99390e3f217d13ddcbaeaed08789f1ca614d663b345b9da42e35ad6b60d696a # via -r requirements/prod.txt -setuptools==80.7.1 \ - --hash=sha256:ca5cc1069b85dc23070a6628e6bcecb3292acac802399c7f8edc0100619f9009 \ - --hash=sha256:f6ffc5f0142b1bd8d0ca94ee91b30c0ca862ffd50826da1ea85258a06fd94552 +setuptools==80.8.0 \ + --hash=sha256:49f7af965996f26d43c8ae34539c8d99c5042fbff34302ea151eaa9c207cd257 \ + --hash=sha256:95a60484590d24103af13b686121328cc2736bee85de8936383111e421b9edc0 # via # -r requirements/prod.txt # supervisor diff --git a/requirements/docs.txt b/requirements/docs.txt index fba9ab373..37209b8ef 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -110,9 +110,9 @@ charset-normalizer==3.4.2 \ --hash=sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a \ --hash=sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f # via requests -click==8.2.0 \ - --hash=sha256:6b303f0b2aa85f1cb4e5303078fadcbcd4e476f114fab9b5007005711839325c \ - --hash=sha256:f5452aeddd9988eefa20f90f05ab66f17fce1ee2a36907fd30b05bbb5953814d +click==8.2.1 \ + --hash=sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202 \ + --hash=sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b # via mkdocs colorama==0.4.6 \ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ diff --git a/requirements/prod.in b/requirements/prod.in index 968138f0b..690414633 100644 --- a/requirements/prod.in +++ b/requirements/prod.in @@ -17,12 +17,14 @@ django-jinja==2.11.0 django-jsonview==2.0.0 django-memoize==2.3.1 django-mozilla-product-details==1.0.3 +django-pagedown==2.2.1 django-rq==3.0.0 django-rq-email-backend==2.0.0 django-storages[google]==1.14.4 django-waffle==4.2.0 django-watchman==1.3.0 Django==5.2.1 +djangorestframework==3.15.2 docutils==0.21.2 envcat==0.1.1 everett==3.4.0 diff --git a/requirements/prod.txt b/requirements/prod.txt index ec7ddb5f1..cc08490dc 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -42,9 +42,9 @@ boto3==1.38.0 \ --hash=sha256:8b6544eca17e31d1bfd538e5d152b96a68d6c92950352a0cd9679f89d217d53a \ --hash=sha256:96898facb164b47859d40a4271007824a0a791c3811a7079ce52459d753d4474 # via -r requirements/prod.in -botocore==1.38.17 \ - --hash=sha256:ec75cf02fbd3dbec18187085ce387761eab16afdccfd0774fd168db3689c6cb6 \ - --hash=sha256:f2db4c4bdcfbc41d78bfe73b9affe7d217c7840f8ce120cff815536969418b18 +botocore==1.38.21 \ + --hash=sha256:08d5e9c00e5cc9e0ae0e60570846011789dc7f1d4ea094b3f3e3f3ae1ff2063a \ + --hash=sha256:567b4d338114174d0b41857002a4b1e8efb68f1654ed9f3ec6c34ebdef5e9eaf # via # boto3 # s3transfer @@ -227,9 +227,9 @@ charset-normalizer==3.4.2 \ --hash=sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a \ --hash=sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f # via requests -click==8.2.0 \ - --hash=sha256:6b303f0b2aa85f1cb4e5303078fadcbcd4e476f114fab9b5007005711839325c \ - --hash=sha256:f5452aeddd9988eefa20f90f05ab66f17fce1ee2a36907fd30b05bbb5953814d +click==8.2.1 \ + --hash=sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202 \ + --hash=sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b # via # granian # rq @@ -318,6 +318,7 @@ django==5.2.1 \ # django-memoize # django-modelcluster # django-mozilla-product-details + # django-pagedown # django-permissionedforms # django-rq # django-rq-email-backend @@ -379,6 +380,10 @@ django-mozilla-product-details==1.0.3 \ --hash=sha256:1d139ba01f4484f3bb43b72864ce33f249835405449e0dc940217cfa42ce5b46 \ --hash=sha256:a4aba6a68b296dffe8c1afb95d236cdbd402bd855cd49eef4d8a1a610105fd36 # via -r requirements/prod.in +django-pagedown==2.2.1 \ + --hash=sha256:2e6373a3f1ec1886ec0bdd16a0db1d5f5d14437a0fdddefa7fba905cef50d6e8 \ + --hash=sha256:9b4204d65334d0add1d3daf2533d91f96b62a11fe89c5e2ca93a31fbd4b38aeb + # via -r requirements/prod.in django-permissionedforms==0.1 \ --hash=sha256:4340bb20c4477fffb13b4cc5cccf9f1b1010b64f79956c291c72d2ad2ed243f8 \ --hash=sha256:d341a961a27cc77fde8cc42141c6ab55cc1f0cb886963cc2d6967b9674fa47d6 @@ -421,10 +426,11 @@ django-watchman==1.3.0 \ --hash=sha256:33b5fc734d689b83cb96fc17beda624ae2955f4cede0856897d990c363eac962 \ --hash=sha256:5f04300bd7fbdd63b8a883b2730ed1e4d9b0f9991133b33a1281134b81f466eb # via -r requirements/prod.in -djangorestframework==3.16.0 \ - --hash=sha256:bea7e9f6b96a8584c5224bfb2e4348dfb3f8b5e34edbecb98da258e892089361 \ - --hash=sha256:f022ff46613584de994c0c6a4aebbace5fd700555fbe9d33b865ebf173eba6c9 +djangorestframework==3.15.2 \ + --hash=sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20 \ + --hash=sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad # via + # -r requirements/prod.in # wagtail # wagtail-localize-smartling docutils==0.21.2 \ @@ -467,9 +473,9 @@ google-api-core==2.24.2 \ # via # google-cloud-core # google-cloud-storage -google-auth==2.40.1 \ - --hash=sha256:58f0e8416a9814c1d86c9b7f6acf6816b51aba167b2c76821965271bac275540 \ - --hash=sha256:ed4cae4f5c46b41bae1d19c036e06f6c371926e97b19e816fc854eff811974ee +google-auth==2.40.2 \ + --hash=sha256:a33cde547a2134273226fa4b853883559947ebe9207521f7afc707efbf690f58 \ + --hash=sha256:f7e568d42eedfded58734f6a60c58321896a621f7c116c411550a4b4a13da90b # via # google-api-core # google-cloud-core @@ -1055,6 +1061,7 @@ pillow==11.2.1 \ --hash=sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b # via # -r requirements/prod.in + # django-pagedown # pillow-heif # wagtail pillow-heif==0.21.0 \ @@ -1287,9 +1294,9 @@ sentry-sdk==2.26.1 \ --hash=sha256:759e019c41551a21519a95e6cef6d91fb4af1054761923dadaee2e6eca9c02c7 \ --hash=sha256:e99390e3f217d13ddcbaeaed08789f1ca614d663b345b9da42e35ad6b60d696a # via -r requirements/prod.in -setuptools==80.7.1 \ - --hash=sha256:ca5cc1069b85dc23070a6628e6bcecb3292acac802399c7f8edc0100619f9009 \ - --hash=sha256:f6ffc5f0142b1bd8d0ca94ee91b30c0ca862ffd50826da1ea85258a06fd94552 +setuptools==80.8.0 \ + --hash=sha256:49f7af965996f26d43c8ae34539c8d99c5042fbff34302ea151eaa9c207cd257 \ + --hash=sha256:95a60484590d24103af13b686121328cc2736bee85de8936383111e421b9edc0 # via supervisor six==1.17.0 \ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ diff --git a/springfield/admin/templates/admin/base_site.html b/springfield/admin/templates/admin/base_site.html index 6f5fd36a3..ec6132d24 100644 --- a/springfield/admin/templates/admin/base_site.html +++ b/springfield/admin/templates/admin/base_site.html @@ -1,4 +1,5 @@ {% extends "admin/base.html" %} +{% load static %} {% comment %} SKIP LICENSE INSERTION {% endcomment %} {% comment %} This Source Code Form is subject to the terms of the Mozilla Public @@ -16,3 +17,7 @@ Logout {% include "admin/color_theme_toggle.html" %} {% endblock userlinks %} + +{% block extrastyle %} + +{% endblock %} diff --git a/springfield/firefox/templates/firefox/releases/notes.html b/springfield/firefox/templates/firefox/releases/notes.html index 18e82d84f..f7c194cb7 100644 --- a/springfield/firefox/templates/firefox/releases/notes.html +++ b/springfield/firefox/templates/firefox/releases/notes.html @@ -144,7 +144,7 @@

{% if release.is_public %}
Version {{ release.version }}, first offered to {{ release.channel }} channel users on {{ release.release_date|l10n_format_date }}
-
{{ release.text|safe }}
+
{{ release.text_html|safe }}
{% else %}

We’re still preparing the notes for this release, and will post them here when they are ready. Please check back later.

{% block extra_draft_description %}{% endblock %} @@ -402,7 +402,7 @@

Other Resources

{% macro note_entry(note) %}
  • - {{ note.note|safe }} + {{ note.note_html|safe }} {% if release.channel == 'Nightly' and note.bug -%} Bug {{ note.bug }} {%- endif %} diff --git a/springfield/firefox/templates/firefox/releases/system_requirements.html b/springfield/firefox/templates/firefox/releases/system_requirements.html index f4a31e3c5..375e328e8 100644 --- a/springfield/firefox/templates/firefox/releases/system_requirements.html +++ b/springfield/firefox/templates/firefox/releases/system_requirements.html @@ -26,7 +26,7 @@

    Firefox System Requirements

    {{ release.product }} {{ channel_name }} {{ version }}

    - {{ release.system_requirements|safe }} + {{ release.system_requirements_html|safe }}
    diff --git a/springfield/releasenotes/admin.py b/springfield/releasenotes/admin.py new file mode 100644 index 000000000..ce4d7cfc9 --- /dev/null +++ b/springfield/releasenotes/admin.py @@ -0,0 +1,165 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from django import forms +from django.contrib import admin +from django.utils.html import format_html +from django.utils.safestring import mark_safe +from django.utils.timezone import now + +from pagedown.widgets import AdminPagedownWidget + +from . import models + +pagedown_widget_help_text = mark_safe( + "If you use an <img> tag, any alt " + "attribute must the last in the tag, else the image " + "will not preview" +) + + +class NoteAdminForm(forms.ModelForm): + note = forms.CharField( + widget=AdminPagedownWidget(), + help_text=pagedown_widget_help_text, + ) + bug = forms.IntegerField( + widget=forms.NumberInput(attrs={"style": "width: 12em;"}), + required=False, + ) + + class Meta: + model = models.Note + fields = "__all__" # noqa: DJ007 + + +class NoteAdmin(admin.ModelAdmin): + form = NoteAdminForm + filter_horizontal = ["releases", "relevant_countries"] + list_display = ("id", "bug", "tag", "note", "created") + list_display_links = ("id",) + list_filter = ("tag", "is_known_issue", "releases__product", "releases__version", "progressive_rollout") + search_fields = ("bug", "note", "releases__version") + + +class ReleaseAdminForm(forms.ModelForm): + system_requirements = forms.CharField( + widget=AdminPagedownWidget(), + required=False, + help_text=pagedown_widget_help_text, + ) + text = forms.CharField( + widget=AdminPagedownWidget(), + required=False, + help_text=pagedown_widget_help_text, + ) + release_date = forms.DateTimeField(widget=admin.widgets.AdminDateWidget) + + class Meta: + model = models.Release + fields = ( + "is_public", + "product", + "channel", + "version", + "release_date", + "text", + "bug_list", + "bug_search_url", + "system_requirements", + "reviewed_by_release_manager", + ) + + +class ReleaseAdmin(admin.ModelAdmin): + actions = ["copy_releases", "set_to_public", "set_to_private"] + form = ReleaseAdminForm + list_display = ("version", "product", "channel", "is_public", "release_date", "text", "url") + list_filter = ("product", "channel", "is_public") + ordering = ("-release_date",) + search_fields = ("version", "text") + + class Media: + js = ["js/release-notes.js"] + + def url(self, obj): + base_url_staging = "https://www-dev.allizom.org/en-US" + base_url_prod = "https://www.mozilla.com/en-US" + product = "" + + if obj.product == "Firefox for Android": + product = "firefox/android" + if obj.product == "Firefox for iOS": + product = "firefox/ios" + elif obj.product == "Firefox" or obj.product == "Firefox Extended Support Release": + product = "firefox" + elif obj.product == "Thunderbird": + product = "thunderbird" + elif obj.product == "Firefox OS": + # Special case for Firefox OS. URL are different + return format_html( + 'Staging / Public', + staging=base_url_staging, + product=product, + version=obj.version, + prod=base_url_prod, + ) + + return format_html( + 'Staging / Public', + staging=base_url_staging, + product=product, + version=obj.version, + prod=base_url_prod, + ) + + def copy_releases(self, request, queryset): + release_count = 0 + for release in queryset: + release_count += 1 + copy_count = self.model.objects.filter(version__endswith=release.version, product=release.product).count() + notes = list(release.note_set.all()) + copy = release + copy.id = None + if copy_count > 1: + copy.version = f"copy{copy_count}-{copy.version}" + else: + copy.version = "copy-" + copy.version + # By default, set it to public. Usually, the copy feature is + # used when copying beta => release. We want to review it + # before going live. + copy.is_public = False + copy.save() + copy.note_set.add(*notes) + copy.note_set.update(modified=now()) + if release_count == 1: + self.message_user(request, "Copied Release") + else: + self.message_user(request, "Copied %s Releases" % release_count) + + def set_to_public(self, request, queryset): + """Set one or several releases to public""" + # save them individually so that signals are sent + for obj in queryset: + obj.is_public = True + obj.save() + + def set_to_private(self, request, queryset): + """Set one or several releases to private""" + # save them individually so that signals are sent + for obj in queryset: + obj.is_public = False + obj.save() + + +class CountryAdmin(admin.ModelAdmin): + search_fields = ("name", "code") + + class Meta: + model = models.Country + + +admin.site.register(models.Country, CountryAdmin) +admin.site.register(models.Note, NoteAdmin) +admin.site.register(models.Release, ReleaseAdmin) diff --git a/springfield/releasenotes/management/__init__.py b/springfield/releasenotes/management/__init__.py deleted file mode 100644 index 448bb8652..000000000 --- a/springfield/releasenotes/management/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at https://mozilla.org/MPL/2.0/. diff --git a/springfield/releasenotes/management/commands/__init__.py b/springfield/releasenotes/management/commands/__init__.py deleted file mode 100644 index 448bb8652..000000000 --- a/springfield/releasenotes/management/commands/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at https://mozilla.org/MPL/2.0/. diff --git a/springfield/releasenotes/management/commands/update_release_notes.py b/springfield/releasenotes/management/commands/update_release_notes.py deleted file mode 100644 index 744b9a237..000000000 --- a/springfield/releasenotes/management/commands/update_release_notes.py +++ /dev/null @@ -1,40 +0,0 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at https://mozilla.org/MPL/2.0/. - -from django.conf import settings -from django.core.management.base import BaseCommand - -from springfield.releasenotes.models import ProductRelease -from springfield.utils.git import GitRepo -from springfield.utils.management.decorators import alert_sentry_on_exception - - -@alert_sentry_on_exception -class Command(BaseCommand): - def add_arguments(self, parser): - parser.add_argument("-q", "--quiet", action="store_true", dest="quiet", default=False, help="If no error occurs, swallow all output.") - parser.add_argument("-f", "--force", action="store_true", dest="force", default=False, help="Load the data even if nothing new from git.") - - def output(self, msg): - if not self.quiet: - print(msg) - - def handle(self, *args, **options): - self.quiet = options["quiet"] - repo = GitRepo(settings.RELEASE_NOTES_PATH, settings.RELEASE_NOTES_REPO, branch_name=settings.RELEASE_NOTES_BRANCH, name="Release Notes") - self.output("Updating git repo") - self.output(repo.update()) - if not (options["force"] or repo.has_changes()): - self.output("No release note updates") - return - - self.output("Loading releases into database") - count = ProductRelease.objects.refresh() - - self.output(f"{count} release notes successfully loaded") - - repo.set_db_latest() - - self.output("Saved latest git repo state to database") - self.output("Done!") diff --git a/springfield/releasenotes/middleware.py b/springfield/releasenotes/middleware.py new file mode 100644 index 000000000..a0be1160d --- /dev/null +++ b/springfield/releasenotes/middleware.py @@ -0,0 +1,14 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +from django.utils.deprecation import MiddlewareMixin + + +METHOD_OVERRIDE_HEADER = "HTTP_X_HTTP_METHOD_OVERRIDE" + + +class PatchOverrideMiddleware(MiddlewareMixin): + def process_view(self, request, callback, callback_args, callback_kwargs): + if request.method == "POST" and request.META.get(METHOD_OVERRIDE_HEADER) == "PATCH": + request.method = "PATCH" diff --git a/springfield/releasenotes/migrations/0003_country_release_note.py b/springfield/releasenotes/migrations/0003_country_release_note.py new file mode 100644 index 000000000..85b9df774 --- /dev/null +++ b/springfield/releasenotes/migrations/0003_country_release_note.py @@ -0,0 +1,74 @@ +# Generated by Django 5.2 on 2025-05-22 15:35 + +import django.db.models.deletion +import django_extensions.db.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('releasenotes', '0002_auto_20200122_0957'), + ] + + operations = [ + migrations.CreateModel( + name='Country', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('name', models.CharField(max_length=128)), + ('code', models.CharField(help_text='3166-1-alpha-2 code', max_length=2)), + ], + options={ + 'verbose_name_plural': 'Countries', + 'ordering': ['name'], + 'constraints': [models.UniqueConstraint(fields=('name', 'code'), name='unique_country_name_for_code')], + }, + ), + migrations.CreateModel( + name='Release', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('product', models.CharField(choices=[('Firefox', 'Firefox'), ('Firefox for Android', 'Firefox for Android'), ('Firefox for iOS', 'Firefox for iOS'), ('Firefox Extended Support Release', 'Firefox Extended Support Release'), ('Thunderbird', 'Thunderbird')], max_length=255)), + ('channel', models.CharField(choices=[('Nightly', 'Nightly'), ('Beta', 'Beta'), ('Release', 'Release'), ('ESR', 'ESR')], max_length=255)), + ('version', models.CharField(max_length=255)), + ('release_date', models.DateTimeField()), + ('text', models.TextField(blank=True)), + ('is_public', models.BooleanField(default=False, help_text='Note: If checked, these Release Notes will be visible on www.firefox.com')), + ('bug_list', models.TextField(blank=True)), + ('bug_search_url', models.CharField(blank=True, max_length=2000)), + ('system_requirements', models.TextField(blank=True)), + ('reviewed_by_release_manager', models.BooleanField(default=False, help_text='Purely a visual indicator in the admin - does not show on firefox.com', null=True)), + ], + options={ + 'ordering': ['-release_date'], + 'get_latest_by': 'modified', + 'unique_together': {('product', 'version')}, + }, + ), + migrations.CreateModel( + name='Note', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('bug', models.IntegerField(blank=True, null=True)), + ('note', models.TextField(blank=True)), + ('is_known_issue', models.BooleanField(default=False)), + ('tag', models.CharField(blank=True, choices=[('New', 'New'), ('Changed', 'Changed'), ('HTML5', 'HTML5'), ('Feature', 'Feature'), ('Language', 'Language'), ('Developer', 'Developer'), ('Enterprise', 'Enterprise'), ('Fixed', 'Fixed'), ('Community', 'Community')], max_length=255)), + ('sort_num', models.IntegerField(default=0)), + ('is_public', models.BooleanField(default=True)), + ('progressive_rollout', models.BooleanField(default=False)), + ('relevant_countries', models.ManyToManyField(blank=True, help_text="Select the countries where this Note applies, as part of a progressive rollout. This info will only be shown on the Release page if 'Progressive rollout', above, is ticked.", to='releasenotes.country')), + ('fixed_in_release', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='fixed_note_set', to='releasenotes.release')), + ('releases', models.ManyToManyField(blank=True, to='releasenotes.release')), + ], + options={ + 'get_latest_by': 'modified', + }, + ), + ] diff --git a/springfield/releasenotes/migrations/0004_data_bootstrap_countries.py b/springfield/releasenotes/migrations/0004_data_bootstrap_countries.py new file mode 100644 index 000000000..603643063 --- /dev/null +++ b/springfield/releasenotes/migrations/0004_data_bootstrap_countries.py @@ -0,0 +1,280 @@ +# Generated by Django 5.2 on 2025-05-06 20:52 + +from django.db import migrations +from django.utils.timezone import now + + +iso_3166_1_alpha_2_country_codes = ( + # Source https://datahub.io/core/country-list + # Public domain license: https://opendatacommons.org/licenses/pddl/ + ("Afghanistan", "AF"), + ("Åland Islands", "AX"), + ("Albania", "AL"), + ("Algeria", "DZ"), + ("American Samoa", "AS"), + ("Andorra", "AD"), + ("Angola", "AO"), + ("Anguilla", "AI"), + ("Antarctica", "AQ"), + ("Antigua and Barbuda", "AG"), + ("Argentina", "AR"), + ("Armenia", "AM"), + ("Aruba", "AW"), + ("Australia", "AU"), + ("Austria", "AT"), + ("Azerbaijan", "AZ"), + ("Bahamas", "BS"), + ("Bahrain", "BH"), + ("Bangladesh", "BD"), + ("Barbados", "BB"), + ("Belarus", "BY"), + ("Belgium", "BE"), + ("Belize", "BZ"), + ("Benin", "BJ"), + ("Bermuda", "BM"), + ("Bhutan", "BT"), + ("Bolivia, Plurinational State of", "BO"), + ("Bonaire, Sint Eustatius and Saba", "BQ"), + ("Bosnia and Herzegovina", "BA"), + ("Botswana", "BW"), + ("Bouvet Island", "BV"), + ("Brazil", "BR"), + ("British Indian Ocean Territory", "IO"), + ("Brunei Darussalam", "BN"), + ("Bulgaria", "BG"), + ("Burkina Faso", "BF"), + ("Burundi", "BI"), + ("Cambodia", "KH"), + ("Cameroon", "CM"), + ("Canada", "CA"), + ("Cape Verde", "CV"), + ("Cayman Islands", "KY"), + ("Central African Republic", "CF"), + ("Chad", "TD"), + ("Chile", "CL"), + ("China", "CN"), + ("Christmas Island", "CX"), + ("Cocos (Keeling) Islands", "CC"), + ("Colombia", "CO"), + ("Comoros", "KM"), + ("Congo", "CG"), + ("Congo, the Democratic Republic of the", "CD"), + ("Cook Islands", "CK"), + ("Costa Rica", "CR"), + ("Côte d'Ivoire", "CI"), + ("Croatia", "HR"), + ("Cuba", "CU"), + ("Curaçao", "CW"), + ("Cyprus", "CY"), + ("Czech Republic", "CZ"), + ("Denmark", "DK"), + ("Djibouti", "DJ"), + ("Dominica", "DM"), + ("Dominican Republic", "DO"), + ("Ecuador", "EC"), + ("Egypt", "EG"), + ("El Salvador", "SV"), + ("Equatorial Guinea", "GQ"), + ("Eritrea", "ER"), + ("Estonia", "EE"), + ("Ethiopia", "ET"), + ("Falkland Islands (Malvinas)", "FK"), + ("Faroe Islands", "FO"), + ("Fiji", "FJ"), + ("Finland", "FI"), + ("France", "FR"), + ("French Guiana", "GF"), + ("French Polynesia", "PF"), + ("French Southern Territories", "TF"), + ("Gabon", "GA"), + ("Gambia", "GM"), + ("Georgia", "GE"), + ("Germany", "DE"), + ("Ghana", "GH"), + ("Gibraltar", "GI"), + ("Greece", "GR"), + ("Greenland", "GL"), + ("Grenada", "GD"), + ("Guadeloupe", "GP"), + ("Guam", "GU"), + ("Guatemala", "GT"), + ("Guernsey", "GG"), + ("Guinea", "GN"), + ("Guinea-Bissau", "GW"), + ("Guyana", "GY"), + ("Haiti", "HT"), + ("Heard Island and McDonald Islands", "HM"), + ("Holy See (Vatican City State)", "VA"), + ("Honduras", "HN"), + ("Hong Kong", "HK"), + ("Hungary", "HU"), + ("Iceland", "IS"), + ("India", "IN"), + ("Indonesia", "ID"), + ("Iran, Islamic Republic of", "IR"), + ("Iraq", "IQ"), + ("Ireland", "IE"), + ("Isle of Man", "IM"), + ("Israel", "IL"), + ("Italy", "IT"), + ("Jamaica", "JM"), + ("Japan", "JP"), + ("Jersey", "JE"), + ("Jordan", "JO"), + ("Kazakhstan", "KZ"), + ("Kenya", "KE"), + ("Kiribati", "KI"), + ("Korea, Democratic People's Republic of", "KP"), + ("Korea, Republic of", "KR"), + ("Kuwait", "KW"), + ("Kyrgyzstan", "KG"), + ("Lao People's Democratic Republic", "LA"), + ("Latvia", "LV"), + ("Lebanon", "LB"), + ("Lesotho", "LS"), + ("Liberia", "LR"), + ("Libya", "LY"), + ("Liechtenstein", "LI"), + ("Lithuania", "LT"), + ("Luxembourg", "LU"), + ("Macao", "MO"), + ("Macedonia, the Former Yugoslav Republic of", "MK"), + ("Madagascar", "MG"), + ("Malawi", "MW"), + ("Malaysia", "MY"), + ("Maldives", "MV"), + ("Mali", "ML"), + ("Malta", "MT"), + ("Marshall Islands", "MH"), + ("Martinique", "MQ"), + ("Mauritania", "MR"), + ("Mauritius", "MU"), + ("Mayotte", "YT"), + ("Mexico", "MX"), + ("Micronesia, Federated States of", "FM"), + ("Moldova, Republic of", "MD"), + ("Monaco", "MC"), + ("Mongolia", "MN"), + ("Montenegro", "ME"), + ("Montserrat", "MS"), + ("Morocco", "MA"), + ("Mozambique", "MZ"), + ("Myanmar", "MM"), + ("Namibia", "NA"), + ("Nauru", "NR"), + ("Nepal", "NP"), + ("Netherlands", "NL"), + ("New Caledonia", "NC"), + ("New Zealand", "NZ"), + ("Nicaragua", "NI"), + ("Niger", "NE"), + ("Nigeria", "NG"), + ("Niue", "NU"), + ("Norfolk Island", "NF"), + ("Northern Mariana Islands", "MP"), + ("Norway", "NO"), + ("Oman", "OM"), + ("Pakistan", "PK"), + ("Palau", "PW"), + ("Palestine, State of", "PS"), + ("Panama", "PA"), + ("Papua New Guinea", "PG"), + ("Paraguay", "PY"), + ("Peru", "PE"), + ("Philippines", "PH"), + ("Pitcairn", "PN"), + ("Poland", "PL"), + ("Portugal", "PT"), + ("Puerto Rico", "PR"), + ("Qatar", "QA"), + ("Réunion", "RE"), + ("Romania", "RO"), + ("Russian Federation", "RU"), + ("Rwanda", "RW"), + ("Saint Barthélemy", "BL"), + ("Saint Helena, Ascension and Tristan da Cunha", "SH"), + ("Saint Kitts and Nevis", "KN"), + ("Saint Lucia", "LC"), + ("Saint Martin (French part)", "MF"), + ("Saint Pierre and Miquelon", "PM"), + ("Saint Vincent and the Grenadines", "VC"), + ("Samoa", "WS"), + ("San Marino", "SM"), + ("Sao Tome and Principe", "ST"), + ("Saudi Arabia", "SA"), + ("Senegal", "SN"), + ("Serbia", "RS"), + ("Seychelles", "SC"), + ("Sierra Leone", "SL"), + ("Singapore", "SG"), + ("Sint Maarten (Dutch part)", "SX"), + ("Slovakia", "SK"), + ("Slovenia", "SI"), + ("Solomon Islands", "SB"), + ("Somalia", "SO"), + ("South Africa", "ZA"), + ("South Georgia and the South Sandwich Islands", "GS"), + ("South Sudan", "SS"), + ("Spain", "ES"), + ("Sri Lanka", "LK"), + ("Sudan", "SD"), + ("Suriname", "SR"), + ("Svalbard and Jan Mayen", "SJ"), + ("Swaziland", "SZ"), + ("Sweden", "SE"), + ("Switzerland", "CH"), + ("Syrian Arab Republic", "SY"), + ("Taiwan, Province of China", "TW"), + ("Tajikistan", "TJ"), + ("Tanzania, United Republic of", "TZ"), + ("Thailand", "TH"), + ("Timor-Leste", "TL"), + ("Togo", "TG"), + ("Tokelau", "TK"), + ("Tonga", "TO"), + ("Trinidad and Tobago", "TT"), + ("Tunisia", "TN"), + ("Turkey", "TR"), + ("Turkmenistan", "TM"), + ("Turks and Caicos Islands", "TC"), + ("Tuvalu", "TV"), + ("Uganda", "UG"), + ("Ukraine", "UA"), + ("United Arab Emirates", "AE"), + ("United Kingdom", "GB"), + ("United States", "US"), + ("United States Minor Outlying Islands", "UM"), + ("Uruguay", "UY"), + ("Uzbekistan", "UZ"), + ("Vanuatu", "VU"), + ("Venezuela, Bolivarian Republic of", "VE"), + ("Viet Nam", "VN"), + ("Virgin Islands, British", "VG"), + ("Virgin Islands, U.S.", "VI"), + ("Wallis and Futuna", "WF"), + ("Western Sahara", "EH"), + ("Yemen", "YE"), + ("Zambia", "ZM"), + ("Zimbabwe", "ZW"), +) + + +def forwards(apps, schema_editor): + Country = apps.get_model("releasenotes.Country") + for name, code in iso_3166_1_alpha_2_country_codes: + Country.objects.create(name=name, code=code, modified=now()) + + +def backwards(apps, schema_editor): + Country = apps.get_model("releasenotes.Country") + Country.objects.all().delete() + + +class Migration(migrations.Migration): + dependencies = [ + ('releasenotes', '0003_country_release_note'), + ] + + operations = [ + migrations.RunPython(forwards, backwards), + ] diff --git a/springfield/releasenotes/migrations/__init__.py b/springfield/releasenotes/migrations/__init__.py index 448bb8652..e69de29bb 100644 --- a/springfield/releasenotes/migrations/__init__.py +++ b/springfield/releasenotes/migrations/__init__.py @@ -1,3 +0,0 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at https://mozilla.org/MPL/2.0/. diff --git a/springfield/releasenotes/models.py b/springfield/releasenotes/models.py index 8e378a721..90b5c5b2b 100644 --- a/springfield/releasenotes/models.py +++ b/springfield/releasenotes/models.py @@ -7,26 +7,32 @@ import os import re import xml.etree.ElementTree as etree +from datetime import timedelta from glob import glob +from itertools import chain from operator import attrgetter from django.conf import settings from django.core.cache import caches from django.db import models, transaction +from django.db.models import Q, UniqueConstraint +from django.forms.models import model_to_dict from django.http import Http404 from django.utils.dateparse import parse_datetime from django.utils.functional import cached_property +from django.utils.text import slugify +from django.utils.timezone import now import bleach import markdown from django_extensions.db.fields.json import JSONField +from django_extensions.db.models import TimeStampedModel from markdown.extensions import Extension from markdown.inlinepatterns import InlineProcessor from product_details.version_compare import Version from springfield.base.urlresolvers import reverse from springfield.releasenotes import version_re -from springfield.releasenotes.utils import memoize class StrikethroughInlineProcessor(InlineProcessor): @@ -137,20 +143,6 @@ def __init__(self, data): setattr(self, key, value) -class Note(RNModel): - id = None - bug = None - note = "" - tag = None - is_public = True - fixed_in_release = None - sort_num = None - created = None - modified = None - progressive_rollout = False - relevant_countries = [] - - class MarkdownField(models.TextField): """Field that takes Markdown text as input and saves HTML to the database""" @@ -317,15 +309,310 @@ def get_notes(self): return process_notes(self.notes) -@memoize(LONG_RN_CACHE_TIMEOUT) +### RNA MODELS + +class ReleaseQuerySet(models.QuerySet): + def product(self, product_name, channel_name=None, version=None): + if product_name.lower() == "firefox extended support release": + product_name = "firefox" + channel_name = "esr" + q = self.filter(product__iexact=product_name) + if channel_name: + q = q.filter(channel__iexact=channel_name) + if version: + q = q.filter(version=version) + + return q.prefetch_related("note_set") + +class ReleaseManager(models.Manager): + def get_queryset(self, include_drafts=False): + qs = ReleaseQuerySet(self.model, using=self._db) + if settings.DEV or settings.WAGTAIL_ENABLE_ADMIN or include_drafts: + return qs + + return qs.filter(is_public=True) + + def product(self, product_name, channel_name=None, version=None, include_drafts=False): + return self.get_queryset(include_drafts).product(product_name, channel_name, version) + + def all_as_list(self): + """Return all releases as a list of dicts""" + return [r.to_dict() for r in self.prefetch_related("note_set").all()] + + def recently_modified_list(self, days_ago=7, mod_date=None): + if mod_date is None: + mod_date = now() - timedelta(days=days_ago) + + query = self.filter(Q(modified__gte=mod_date) | Q(note__modified__gte=mod_date) | Q(fixed_note_set__modified__gte=mod_date)).distinct() + return [r.to_dict() for r in query.prefetch_related("note_set")] + + +class Release(TimeStampedModel): + CHANNELS = ("Nightly", "Beta", "Release", "ESR") + PRODUCTS = ("Firefox", "Firefox for Android", "Firefox for iOS", "Firefox Extended Support Release", "Thunderbird") + PUBLIC_ONLY = not (settings.DEV or settings.WAGTAIL_ENABLE_ADMIN) + + product = models.CharField(max_length=255, choices=[(p, p) for p in PRODUCTS]) + channel = models.CharField(max_length=255, choices=[(c, c) for c in CHANNELS]) + version = models.CharField(max_length=255) + release_date = models.DateTimeField() + text = models.TextField(blank=True) + is_public = models.BooleanField(default=False, help_text="Note: If checked, these Release Notes will be visible on www.firefox.com") + bug_list = models.TextField(blank=True) + bug_search_url = models.CharField(max_length=2000, blank=True) + system_requirements = models.TextField(blank=True) + reviewed_by_release_manager = models.BooleanField( + null=True, + default=False, + help_text="Purely a visual indicator in the admin - does not show on firefox.com", + ) + + objects = ReleaseManager() + + @property + def slug(self): + product = slugify(self.product) + channel = self.channel.lower() + if product.lower() == "firefox-extended-support-release": + product = "firefox" + channel = "esr" + return "-".join([product, self.version, channel]) + + @property + def text_html(self): + return process_markdown(self.text) + + @property + def system_requirements_html(self): + return process_markdown(self.system_requirements) + + @cached_property + def major_version(self): + return str(self.version_obj.major) + + @cached_property + def major_version_int(self): + return self.version_obj.major or 0 + + @cached_property + def version_obj(self): + return Version(self.version) + + def get_bug_search_url(self): + if self.bug_search_url: + return self.bug_search_url + + if self.product == "Thunderbird": + return ( + "https://bugzilla.mozilla.org/buglist.cgi?" + "classification=Client%20Software&query_format=advanced&" + "bug_status=RESOLVED&bug_status=VERIFIED&bug_status=CLOSED&" + "target_milestone=Thunderbird%20{version}.0&product=Thunderbird" + "&resolution=FIXED" + ).format(version=self.major_version) + + return ( + "https://bugzilla.mozilla.org/buglist.cgi?" + "j_top=OR&f1=target_milestone&o3=equals&v3=Firefox%20{version}&" + "o1=equals&resolution=FIXED&o2=anyexact&query_format=advanced&" + "f3=target_milestone&f2=cf_status_firefox{version}&" + "bug_status=RESOLVED&bug_status=VERIFIED&bug_status=CLOSED&" + "v1=mozilla{version}&v2=fixed%2Cverified&limit=0" + ).format(version=self.major_version) + + def equivalent_release_for_product(self, product): + """ + Returns the release for a specified product with the same + channel and major version with the highest minor version, + or None if no such releases exist + """ + releases = Release.objects.filter(version__startswith=self.major_version + ".", channel=self.channel, product=product).order_by( + "-version" + ) + if not getattr(settings, "DEV", False): + releases = releases.filter(is_public=True) + if releases: + return sorted( + sorted(releases, reverse=True, key=lambda r: len(r.version.split("."))), reverse=True, key=lambda r: r.version.split(".")[1] + )[0] + + def equivalent_android_release(self): + if self.product == "Firefox": + return self.equivalent_release_for_product("Firefox for Android") + + def equivalent_desktop_release(self): + if self.product == "Firefox for Android": + return self.equivalent_release_for_product("Firefox") + + def notes(self, public_only=None): + """ + Retrieve a list of Note instances that should be shown for this + release, grouped as either new features or known issues, and sorted + first by sort_num highest to lowest and then by created date, + which is applied to both groups, + and then for new features we also sort by tag in the order specified + by Note.TAGS, with untagged notes coming first, then finally moving + any note with the fixed tag that starts with the release version to + the top, for what we call "dot fixes". + """ + if public_only is None: + public_only = self.PUBLIC_ONLY + + tag_index = {tag: i for i, tag in enumerate(Note.TAGS)} + notes = self.note_set.order_by("-sort_num", "created") + if public_only: + notes = notes.filter(is_public=True) + known_issues = [n for n in notes if n.is_known_issue_for(self)] + new_features = sorted( + sorted((n for n in notes if not n.is_known_issue_for(self)), key=lambda note: tag_index.get(note.tag, 0)), + key=lambda n: n.tag == "Fixed" and n.note.startswith(self.version), + reverse=True, + ) + + return new_features, known_issues + + def get_notes(self): + new_features, known_issues = self.notes(public_only=False) + return list(chain(new_features, known_issues)) + + def to_dict(self): + """Return a dict all all data about the release""" + data = model_to_dict(self, exclude=["id", "reviewed_by_release_manager"]) + data["title"] = str(self) + data["slug"] = self.slug + data["release_date"] = self.release_date.date().isoformat() + data["created"] = self.created.isoformat() + data["modified"] = self.modified.isoformat() + new_features, known_issues = self.notes(public_only=False) + for note in known_issues: + note.tag = "Known" + data["notes"] = [n.to_dict(self) for n in chain(new_features, known_issues)] + return data + + def to_simple_dict(self): + """Return a dict of only the basic data about the release""" + return { + "version": self.version, + "product": self.product, + "channel": self.channel, + "is_public": self.is_public, + "slug": self.slug, + "title": str(self), + } + + def __str__(self): + return f"{self.product} {self.version} {self.channel}" + + class Meta: + ordering = ["-release_date"] + unique_together = (("product", "version"),) + get_latest_by = "modified" + + +class Note(TimeStampedModel): + TAGS = ("New", "Changed", "HTML5", "Feature", "Language", "Developer", "Enterprise", "Fixed", "Community") + + bug = models.IntegerField(null=True, blank=True) + note = models.TextField(blank=True) + releases = models.ManyToManyField(Release, blank=True) + is_known_issue = models.BooleanField(default=False) + fixed_in_release = models.ForeignKey(Release, on_delete=models.SET_NULL, null=True, blank=True, related_name="fixed_note_set") + tag = models.CharField(max_length=255, blank=True, choices=[(t, t) for t in TAGS]) + sort_num = models.IntegerField(default=0) + is_public = models.BooleanField(default=True) + progressive_rollout = models.BooleanField(default=False) + relevant_countries = models.ManyToManyField( + "Country", + blank=True, + help_text=( + "Select the countries where this Note applies, as part of a " + "progressive rollout. This info will only be shown on the Release " + "page if 'Progressive rollout', above, is ticked." + ), + ) + + related_field_to_github = "releases" + + @property + def note_html(self): + return process_markdown(self.note) + + def is_known_issue_for(self, release): + return self.is_known_issue and self.fixed_in_release != release + + def to_dict(self, release=None): + data = model_to_dict( + self, + exclude=[ + "releases", + "is_known_issue", + ], + ) + data["created"] = self.created.isoformat() + data["modified"] = self.modified.isoformat() + if self.fixed_in_release: + data["fixed_in_release"] = self.fixed_in_release.to_simple_dict() + else: + del data["fixed_in_release"] + + if release and self.is_known_issue_for(release): + data["tag"] = "Known" + + if self.relevant_countries.count(): + data["relevant_countries"] = [x.to_dict() for x in self.relevant_countries.all()] + + return data + + def __str__(self): + return self.note + + class Meta: + get_latest_by = "modified" + + +class Country(TimeStampedModel): + # Simple model for associating a Country with a Note + + name = models.CharField( + blank=False, + max_length=128, + ) + code = models.CharField( + blank=False, + help_text="3166-1-alpha-2 code", + max_length=2, + ) + + def __str__(self): + return f"{self.name} ({self.code})" + + class Meta: + verbose_name_plural = "Countries" + ordering = ["name"] + constraints = [ + UniqueConstraint( + fields=["name", "code"], + name="unique_country_name_for_code", + ), + ] + + def to_dict(self): + data = model_to_dict(self) + del data["id"] # no need to dump out the internal ID for a Country + return data + + +### END RNA MODELS + + def get_release(product, version, channel=None, include_drafts=False): - channels = [channel] if channel else ProductRelease.CHANNELS + channels = [channel] if channel else Release.CHANNELS if product.lower() == "firefox extended support release": channels = ["esr"] for channel in channels: try: - return ProductRelease.objects.product(product, channel, version, include_drafts).get() - except ProductRelease.DoesNotExist: + return Release.objects.product(product, channel, version, include_drafts).get() + except Release.DoesNotExist: continue return None @@ -339,9 +626,8 @@ def get_release_or_404(version, product, include_drafts=False): return release -@memoize(LONG_RN_CACHE_TIMEOUT) def get_releases(product, channel, num_results=10): - return ProductRelease.objects.product(product, channel)[:num_results] + return Release.objects.product(product, channel)[:num_results] def get_releases_or_404(product, channel, num_results=10): @@ -352,10 +638,9 @@ def get_releases_or_404(product, channel, num_results=10): raise Http404 -@memoize(LONG_RN_CACHE_TIMEOUT) def get_latest_release(product, channel="release"): try: - release = ProductRelease.objects.product(product, channel)[0] + release = Release.objects.product(product, channel)[0] except IndexError: release = None diff --git a/springfield/releasenotes/serializers.py b/springfield/releasenotes/serializers.py new file mode 100644 index 000000000..c3c942fb3 --- /dev/null +++ b/springfield/releasenotes/serializers.py @@ -0,0 +1,40 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from rest_framework import serializers + +from .models import Country, Note, Release + + +class HyperlinkedModelSerializerWithPkField(serializers.HyperlinkedModelSerializer): + def get_default_field_names(self, declared_fields, model_info): + fields = super().get_default_field_names(declared_fields, model_info) + fields.append("id") + return fields + + +class CountrySerializer(serializers.ModelSerializer): + class Meta: + model = Country + fields = [ + "name", + "code", + ] + + +class NoteSerializer(HyperlinkedModelSerializerWithPkField): + relevant_countries = CountrySerializer( + read_only=True, + many=True, + ) + + class Meta: + model = Note + fields = "__all__" + + +class ReleaseSerializer(HyperlinkedModelSerializerWithPkField): + class Meta: + model = Release + fields = "__all__" diff --git a/springfield/releasenotes/templates/admin/base_site.html b/springfield/releasenotes/templates/admin/base_site.html new file mode 100644 index 000000000..3832a0431 --- /dev/null +++ b/springfield/releasenotes/templates/admin/base_site.html @@ -0,0 +1,6 @@ +{% extends "admin/base_site.html" %} +{% load static %} + +{% block extrastyle %} + +{% endblock %} \ No newline at end of file diff --git a/springfield/releasenotes/templates/admin/releasenotes/note/change_form.html b/springfield/releasenotes/templates/admin/releasenotes/note/change_form.html new file mode 100644 index 000000000..6939fe4b5 --- /dev/null +++ b/springfield/releasenotes/templates/admin/releasenotes/note/change_form.html @@ -0,0 +1,20 @@ +{% extends "admin/change_form.html" %} + +{% block footer %} + {{ block.super }} + +{% endblock %} + +{% comment %} +vim: filetype=htmldjango +{% endcomment %} diff --git a/springfield/releasenotes/templates/admin/releasenotes/release/change_form.html b/springfield/releasenotes/templates/admin/releasenotes/release/change_form.html new file mode 100644 index 000000000..66eb703bf --- /dev/null +++ b/springfield/releasenotes/templates/admin/releasenotes/release/change_form.html @@ -0,0 +1,33 @@ +{% extends "admin/change_form.html" %} +{% load static %} + +{% block extrahead %} + {{ block.super }} + +{% endblock %} + +{% block after_related_objects %} +{% if original %} + +
    +
    +

    Notes

    +
    +

    + + + +

    +
    +
    + +{% endif %} +{% endblock %} + +{% comment %} +vim: filetype=htmldjango +{% endcomment %} diff --git a/springfield/releasenotes/tests/releases/.latest-update-etag b/springfield/releasenotes/tests/releases/.latest-update-etag deleted file mode 100644 index 35ba45e95..000000000 --- a/springfield/releasenotes/tests/releases/.latest-update-etag +++ /dev/null @@ -1 +0,0 @@ -"bae656422b8d046543540f42b1658938f" diff --git a/springfield/releasenotes/tests/releases/firefox-24.5.0-esr.json b/springfield/releasenotes/tests/releases/firefox-24.5.0-esr.json deleted file mode 100644 index 9b52880f1..000000000 --- a/springfield/releasenotes/tests/releases/firefox-24.5.0-esr.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "bug_list": "", - "bug_search_url": "https://bugzilla.mozilla.org/buglist.cgi?f1=cf_status_firefox_esr24&o1=anywordssubstr&o2=equals&query_format=advanced&f2=cf_tracking_firefox_esr24&v1=fixed%20verified&v2=29%2B", - "channel": "ESR", - "created": "2014-04-24T21:49:38+00:00", - "is_public": true, - "modified": "2014-06-10T08:59:37+00:00", - "notes": [ - { - "bug": null, - "created": "2013-11-25T00:24:50+00:00", - "id": 107, - "is_public": true, - "modified": "2015-05-12T16:59:10+00:00", - "note": "Security fixes can be found here", - "sort_num": 0, - "tag": "Fixed" - } - ], - "product": "Firefox Extended Support Release", - "release_date": "2014-04-29", - "slug": "firefox-24.5.0-esr", - "system_requirements": " ", - "text": " ", - "title": "Firefox Extended Support Release 24.5.0 ESR", - "version": "24.5.0" -} diff --git a/springfield/releasenotes/tests/releases/firefox-45.0esr-esr.json b/springfield/releasenotes/tests/releases/firefox-45.0esr-esr.json deleted file mode 100644 index fc56d50c0..000000000 --- a/springfield/releasenotes/tests/releases/firefox-45.0esr-esr.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "bug_list": "", - "bug_search_url": "", - "channel": "ESR", - "created": "2016-03-08T15:21:24.120000+00:00", - "is_public": true, - "modified": "2016-08-22T09:09:17.746000+00:00", - "notes": [ - { - "bug": null, - "created": "2016-03-08T15:23:04.674000+00:00", - "id": 786524, - "is_public": true, - "modified": "2017-03-01T14:21:10.798000+00:00", - "note": "[Complete 45.0 release notes][1]\r\n\r\n\r\n [1]: /en-US/firefox/45.0/releasenotes/", - "sort_num": 0, - "tag": "New" - }, - { - "bug": 1232029, - "created": "2016-03-08T15:24:17.674000+00:00", - "id": 786525, - "is_public": true, - "modified": "2017-03-01T14:30:28.655000+00:00", - "note": "[Service workers disabled][1]\r\n\r\n\r\n [1]: https://www.fxsitecompat.com/en-CA/docs/2016/service-workers-have-been-disabled-in-firefox-45-esr/", - "sort_num": 0, - "tag": "Changed" - }, - { - "bug": 1241501, - "created": "2016-03-08T15:25:36.574000+00:00", - "id": 786526, - "is_public": true, - "modified": "2017-03-01T14:30:36.099000+00:00", - "note": "Firefox Hello disabled\r\n", - "sort_num": 0, - "tag": "Changed" - } - ], - "product": "Firefox Extended Support Release", - "release_date": "2016-03-08", - "slug": "firefox-45.0esr-esr", - "system_requirements": "## Windows\r\n\r\n### Operating Systems (32-bit and 64-bit)\r\n - Windows XP SP2\r\n - Windows Server 2003 SP1\r\n - Windows Vista\r\n - Windows 7\r\n - Windows 8\r\n - Windows 10\r\n\r\n*Please note that 64-bit builds of Firefox are only supported on Windows 7 and higher.*\r\n\r\n*To install [Firefox on a Windows XP system][1], because of Windows restrictions, the user will have to download Firefox 43.0.1 and then update to the current release.*\r\n\r\n### Recommended Hardware\r\n- Pentium 4 or newer processor that supports SSE2\r\n- 512MB of RAM\r\n- 200MB of hard drive space\r\n\r\n---\r\n\r\n## Mac\r\n\r\n### Operating Systems\r\n- Mac OS X 10.6\r\n- Mac OS X 10.7\r\n- Mac OS X 10.8\r\n- Mac OS X 10.9\r\n- Mac OS X 10.10\r\n- Mac OS X 10.11\r\n\r\n### Recommended Hardware\r\n- Macintosh computer with an Intel x86 processor\r\n- 512 MB of RAM\r\n- 200 MB hard drive space\r\n\r\n---\r\n\r\n## GNU/Linux\r\n\r\n### Software Requirements\r\n\r\n*Please note that GNU/Linux distributors may provide packages for your distribution which have different requirements.*\r\n\r\n- Firefox will not run at all without the following libraries or packages:\r\n - GTK+ 3.4 or higher\r\n - GLib 2.22 or higher\r\n - Pango 1.14 or higher\r\n - X.Org 1.0 or higher (1.7 or higher is recommended)\r\n - libstdc++ 4.3 or higher\r\n- For optimal functionality, we recommend the following libraries or packages:\r\n - NetworkManager 0.7 or higher\r\n - DBus 1.0 or higher\r\n - HAL 0.5.8 or higher\r\n - GNOME 2.16 or higher\r\n\r\n [1]: https://support.mozilla.org/kb/get-latest-version-firefox-windows-xp-vista", - "text": "", - "title": "Firefox Extended Support Release 45.0esr ESR", - "version": "45.0esr" -} diff --git a/springfield/releasenotes/tests/releases/firefox-55.0a1-nightly.json b/springfield/releasenotes/tests/releases/firefox-55.0a1-nightly.json deleted file mode 100644 index 800e59365..000000000 --- a/springfield/releasenotes/tests/releases/firefox-55.0a1-nightly.json +++ /dev/null @@ -1,205 +0,0 @@ -{ - "bug_list": "1335905", - "bug_search_url": "", - "channel": "Nightly", - "created": "2017-03-21T13:19:13.668000+00:00", - "is_public": true, - "modified": "2017-05-30T18:51:00.765000+00:00", - "notes": [ - { - "bug": 1218482, - "created": "2017-06-06T13:07:52.986000+00:00", - "id": 787117, - "is_public": true, - "modified": "2017-08-08T06:49:53.641000+00:00", - "note": "Launched Windows support for [WebVR][1], bringing immersive experiences to the web. See examples and try working demos at [Mozilla VR][2].\r\n\r\n\r\n [1]: https://developer.mozilla.org/en-US/docs/Web/API/WebVR_API\r\n [2]: https://mozvr.com/", - "sort_num": 10000, - "tag": "New" - }, - { - "bug": 1365998, - "created": "2017-06-02T13:07:35.562000+00:00", - "id": 787108, - "is_public": true, - "modified": "2017-08-08T13:08:09.659000+00:00", - "note": "Simplified installation process with a streamlined Windows stub installer\r\n\r\n - Firefox for Windows 64-bit is now installed by default on 64-bit systems with at least 2GB of RAM\r\n - [Full installers][1] with advanced installation options are still available \r\n\r\n[1]: https://www.firefox.com/download/all/", - "sort_num": 999, - "tag": "New" - }, - { - "bug": 1355331, - "created": "2017-04-24T07:57:30.608000+00:00", - "id": 787058, - "is_public": true, - "modified": "2017-08-08T06:51:01.191000+00:00", - "note": "Updated [Sidebar for bookmarks, history, and synced tabs][1] so it can appear at the right edge of the window as well as the left\r\n\r\n[1]: https://support.mozilla.org/kb/firefox-sidebar-access-bookmarks-history-social\r\n", - "sort_num": 900, - "tag": "New" - }, - { - "bug": 1362526, - "created": "2017-06-05T21:40:21.983000+00:00", - "id": 787115, - "is_public": true, - "modified": "2017-08-08T19:02:36.800000+00:00", - "note": "Pages can be simplified before printing from within Print Preview", - "sort_num": 400, - "tag": "New" - }, - { - "bug": 429824, - "created": "2017-06-05T21:31:14.641000+00:00", - "id": 787114, - "is_public": true, - "modified": "2017-08-08T06:51:49.291000+00:00", - "note": "Updated Firefox for OSX and macOS to allow users to assign [custom keyboard shortcuts][1] to Firefox menu items via System Preferences \r\n\r\n\r\n [1]: https://support.mozilla.org/en-US/kb/keyboard-shortcuts-perform-firefox-tasks-quickly", - "sort_num": 300, - "tag": "New" - }, - { - "bug": 1355082, - "created": "2017-04-19T16:11:03.099000+00:00", - "id": 787056, - "is_public": true, - "modified": "2017-06-05T14:46:22.654000+00:00", - "note": "All locales are now available for Nightly", - "sort_num": 3, - "tag": "New" - }, - { - "bug": 1345090, - "created": "2017-05-17T14:47:50.171000+00:00", - "id": 787091, - "is_public": true, - "modified": "2017-08-01T13:51:16.107000+00:00", - "note": "Browsing sessions with a high number of tabs are now restored in an instant", - "sort_num": 2, - "tag": "New" - }, - { - "bug": 1346488, - "created": "2017-05-24T13:14:31.757000+00:00", - "id": 787105, - "is_public": true, - "modified": "2017-06-05T14:44:47.117000+00:00", - "note": "The Photon project aiming at refreshing the user interface started being implemented in Nightly. These changes are only visible on the nightly channel and will not land in the beta and release channels before at least version 57.", - "sort_num": 1, - "tag": "New" - }, - { - "bug": 1364070, - "created": "2017-05-29T14:18:06.077000+00:00", - "id": 787106, - "is_public": true, - "modified": "2017-08-08T06:25:05.078000+00:00", - "note": "[Fine-tune your browser performance][1] from the Preferences/Options page.\r\n\r\n[1]: https://support.mozilla.org/kb/performance-settings", - "sort_num": 0, - "tag": "New" - }, - { - "bug": 1356243, - "created": "2017-05-30T18:50:40.058000+00:00", - "id": 787107, - "is_public": true, - "modified": "2017-08-01T13:51:16.107000+00:00", - "note": "Make [screenshots][1] of webpages, and save them locally or upload them to the cloud. This feature will undergo A/B testing and will not be visible for some users.\r\n\r\n[1]: https://screenshots.firefox.com/", - "sort_num": 0, - "tag": "New" - }, - { - "bug": 1344928, - "created": "2017-06-06T13:49:34.896000+00:00", - "id": 787119, - "is_public": true, - "modified": "2017-08-01T15:08:42.498000+00:00", - "note": " Search suggestions are now enabled by default for users who haven't explicitly opted-out", - "sort_num": 0, - "tag": "New" - }, - { - "bug": 893505, - "created": "2017-04-18T16:40:23.196000+00:00", - "fixed_in_release": { - "channel": "Nightly", - "is_public": true, - "product": "Firefox", - "slug": "firefox-55.0a1-nightly", - "title": "Firefox 55.0a1 Nightly", - "version": "55.0a1" - }, - "id": 787048, - "is_public": true, - "modified": "2017-08-01T13:51:16.107000+00:00", - "note": "Modernized application update UI to be less intrusive and more aligned with the rest of the browser. Only users who have not restarted their browser 8 days after downloading an update or users who opted out of automatic updates will see this change.", - "sort_num": 0, - "tag": "Changed" - }, - { - "bug": 548311, - "created": "2017-04-19T13:12:28.496000+00:00", - "id": 787051, - "is_public": true, - "modified": "2017-06-06T15:04:57.628000+00:00", - "note": "The default font for Japanese text is now Meiryo", - "sort_num": 0, - "tag": "Changed" - }, - { - "bug": 977177, - "created": "2017-04-19T13:16:55.825000+00:00", - "id": 787052, - "is_public": true, - "modified": "2017-08-08T06:40:10.406000+00:00", - "note": "Firefox does not support downgrades, even though this may have worked in past versions. Users who install Firefox 55+ and later downgrade to an earlier version may experience issues with Firefox. \r\n", - "sort_num": 0, - "tag": "Changed" - }, - { - "bug": 1341897, - "created": "2017-04-19T16:07:16.327000+00:00", - "id": 787055, - "is_public": true, - "modified": "2017-06-05T14:47:28.870000+00:00", - "note": "Firefox uses Mozilla Location Service for geolocation", - "sort_num": 0, - "tag": "Changed" - }, - { - "bug": 1335907, - "created": "2017-04-19T16:17:08.859000+00:00", - "id": 787057, - "is_public": true, - "modified": "2017-06-05T14:44:14.166000+00:00", - "note": "about:preferences has been cleaned up and reorganized to make it easier for you to find what you need", - "sort_num": 0, - "tag": "Changed" - }, - { - "bug": 1317856, - "created": "2017-06-06T13:26:48.862000+00:00", - "id": 787118, - "is_public": true, - "modified": "2017-08-08T06:39:42.278000+00:00", - "note": "Made the Adobe Flash plugin click-to-activate by default and allowed only on http:// and https:// URL schemes. (This change will not be visible to all users immediately. For more information see the [Firefox plugin roadmap][1])\r\n\r\n\r\n[1]:https://developer.mozilla.org/docs/Plugins/Roadmap\r\n\r\n", - "sort_num": 0, - "tag": "Changed" - }, - { - "bug": 1072859, - "created": "2017-04-19T13:19:25.800000+00:00", - "id": 787053, - "is_public": true, - "modified": "2017-08-08T06:48:39.244000+00:00", - "note": "Sites that don\u2019t use SSL can no longer access Geolocation APIs to determine a user\u2019s physical location\r\n", - "sort_num": 0, - "tag": "Developer" - } - ], - "product": "Firefox", - "release_date": "2017-03-06", - "slug": "firefox-55.0a1-nightly", - "system_requirements": "## Windows\r\n\r\n### Operating Systems (32-bit and 64-bit)\r\n - Windows 7\r\n - Windows 8\r\n - Windows 10\r\n\r\n### Recommended Hardware\r\n- Pentium 4 or newer processor that supports SSE2\r\n- 512MB of RAM\r\n- 200MB of hard drive space\r\n\r\n---\r\n\r\n## Mac\r\n\r\n### Operating Systems\r\n- Mac OS X 10.9\r\n- Mac OS X 10.10\r\n- Mac OS X 10.11\r\n- Mac OS X 10.12\r\n\r\n### Recommended Hardware\r\n- Macintosh computer with an Intel x86 processor\r\n- 512 MB of RAM\r\n- 200 MB hard drive space\r\n\r\n---\r\n\r\n## GNU/Linux\r\n\r\n### Software Requirements\r\n\r\n*Please note that GNU/Linux distributors may provide packages for your distribution which have different requirements.*\r\n\r\n- Firefox will not run at all without the following libraries or packages:\r\n - GTK+ 3.4 or higher\r\n - GLib 2.22 or higher\r\n - Pango 1.14 or higher\r\n - X.Org 1.0 or higher (1.7 or higher is recommended)\r\n - libstdc++ 4.6.1 or higher\r\n- For optimal functionality, we recommend the following libraries or packages:\r\n - NetworkManager 0.7 or higher\r\n - DBus 1.0 or higher\r\n - GNOME 2.16 or higher\r\n - PulseAudio\r\n\r\n [1]: https://support.mozilla.org/kb/get-latest-version-firefox-windows-xp-vista", - "text": "Firefox Nightly gets a new version every day and as a consequence, the release notes for the Nightly channel are updated continuously to reflect features that have reached sufficient maturity to benefit from community feedback and bug reports. \r\n\r\nFeatures listed here may or may not make a final release of Firefox.", - "title": "Firefox 55.0a1 Nightly", - "version": "55.0a1" -} diff --git a/springfield/releasenotes/tests/releases/firefox-56.0-release.json b/springfield/releasenotes/tests/releases/firefox-56.0-release.json deleted file mode 100644 index d8e58b401..000000000 --- a/springfield/releasenotes/tests/releases/firefox-56.0-release.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "product": "Firefox", - "channel": "Release", - "version": "56.0", - "release_date": "2017-08-02", - "created": "2017-03-21T13:19:13.668000+00:00", - "modified": "2017-03-21T13:19:13.668000+00:00", - "is_public": true -} diff --git a/springfield/releasenotes/tests/releases/firefox-56.0a1-nightly.json b/springfield/releasenotes/tests/releases/firefox-56.0a1-nightly.json deleted file mode 100644 index de6d3551f..000000000 --- a/springfield/releasenotes/tests/releases/firefox-56.0a1-nightly.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "bug_list": "", - "bug_search_url": "", - "channel": "Nightly", - "created": "2017-06-12T13:42:25.322000+00:00", - "is_public": true, - "modified": "2017-08-17T08:24:32.975000+00:00", - "notes": [ - { - "bug": 1353954, - "created": "2017-06-15T09:07:15.524000+00:00", - "id": 787156, - "is_public": true, - "modified": "2017-06-15T09:11:36.233000+00:00", - "note": "Search within the Options/Preferences panel to find relevant options across categories\r\n", - "sort_num": 0, - "tag": "New" - }, - { - "bug": 1372309, - "created": "2017-06-16T15:35:13.038000+00:00", - "id": 787157, - "is_public": true, - "modified": "2017-06-16T15:58:42.656000+00:00", - "note": "\r\nThe application menu was [restructured][1] as part of the Photon redesign for Firefox 57. Nightly users are encouraged to [flle bugs][2] for any regression they may find.\r\n\r\n\r\n[1]: https://dolske.wordpress.com/2017/06/15/photon-engineering-newsletter-6/ \r\n[2]: https://bugzilla.mozilla.org/enter_bug.cgi?product=Firefox&component=Toolbars%20and%20Customization", - "sort_num": 0, - "tag": "Changed" - } - ], - "product": "Firefox", - "release_date": "2017-06-12", - "slug": "firefox-56.0a1-nightly", - "system_requirements": "## Windows\r\n\r\n### Operating Systems (32-bit and 64-bit)\r\n - Windows 7\r\n - Windows 8\r\n - Windows 10\r\n\r\n### Recommended Hardware\r\n- Pentium 4 or newer processor that supports SSE2\r\n- 512MB of RAM\r\n- 200MB of hard drive space\r\n\r\n---\r\n\r\n## Mac\r\n\r\n### Operating Systems\r\n- Mac OS X 10.9\r\n- Mac OS X 10.10\r\n- Mac OS X 10.11\r\n- Mac OS X 10.12\r\n\r\n### Recommended Hardware\r\n- Macintosh computer with an Intel x86 processor\r\n- 512 MB of RAM\r\n- 200 MB hard drive space\r\n\r\n---\r\n\r\n## GNU/Linux\r\n\r\n### Software Requirements\r\n\r\n*Please note that GNU/Linux distributors may provide packages for your distribution which have different requirements.*\r\n\r\n- Firefox will not run at all without the following libraries or packages:\r\n - GTK+ 3.4 or higher\r\n - GLib 2.22 or higher\r\n - Pango 1.14 or higher\r\n - X.Org 1.0 or higher (1.7 or higher is recommended)\r\n - libstdc++ 4.6.1 or higher\r\n- For optimal functionality, we recommend the following libraries or packages:\r\n - NetworkManager 0.7 or higher\r\n - DBus 1.0 or higher\r\n - GNOME 2.16 or higher\r\n - PulseAudio\r\n\r\n [1]: https://support.mozilla.org/kb/get-latest-version-firefox-windows-xp-vista", - "text": "Firefox Nightly gets a new version every day and as a consequence, the release notes for the Nightly channel are updated continuously to reflect features that have reached sufficient maturity to benefit from community feedback and bug reports. Features listed here may or may not make a final release of Firefox.", - "title": "Firefox 56.0a1 Nightly", - "version": "56.0a1" -} diff --git a/springfield/releasenotes/tests/releases/firefox-57.0a1-nightly.json b/springfield/releasenotes/tests/releases/firefox-57.0a1-nightly.json deleted file mode 100644 index a64552615..000000000 --- a/springfield/releasenotes/tests/releases/firefox-57.0a1-nightly.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "bug_list": "", - "bug_search_url": "", - "channel": "Nightly", - "created": "2017-08-02T14:20:49.003000+00:00", - "is_public": true, - "modified": "2017-09-13T08:55:02.648000+00:00", - "notes": [ - { - "bug": 1387254, - "created": "2017-08-07T08:00:48.092000+00:00", - "id": 787203, - "is_public": true, - "modified": "2017-08-07T08:00:48.092000+00:00", - "note": "Firefox Nightly has a new logo", - "sort_num": 0, - "tag": "New" - }, - { - "bug": 1373650, - "created": "2017-08-17T08:23:04.304000+00:00", - "id": 787237, - "is_public": true, - "modified": "2017-09-11T07:02:27.350000+00:00", - "note": "Added an option to the Page Action menu to report a website issue in Firefox Nightly and Firefox Dev Edition", - "sort_num": 0, - "tag": "New" - }, - { - "bug": 1349227, - "created": "2017-08-31T07:52:29.636000+00:00", - "id": 787243, - "is_public": false, - "modified": "2017-08-31T07:55:06.229000+00:00", - "note": "Firefox Nightly now gets updated twice a day so as to help our communities in EMEA and Asia regions to catch regressions and report them during the Americas night.", - "sort_num": 0, - "tag": "New" - }, - { - "bug": 1388902, - "created": "2017-09-04T11:13:42.634000+00:00", - "id": 787244, - "is_public": false, - "modified": "2017-09-04T11:13:42.634000+00:00", - "note": " The \"Share\" button was removed. If you relied on this feature, you can install the [Share Backported][1] extension instead.\r\n\r\n[1]: https://addons.mozilla.org/firefox/addon/share-backported/", - "sort_num": 0, - "tag": "Changed" - }, - { - "bug": 1346488, - "created": "2017-09-07T13:00:41.753000+00:00", - "id": 787245, - "is_public": true, - "modified": "2017-09-07T13:00:41.753000+00:00", - "note": "Firefox is getting a major Visual Redesign ([Photon project][1]) activated on the Nightly channel and which will ship with Firefox 57 on the release channel.\r\n\r\n[1]: https://wiki.mozilla.org/Firefox/Photon/Updates", - "sort_num": 0, - "tag": "Changed" - }, - { - "bug": 1385463, - "created": "2017-09-07T13:21:44.911000+00:00", - "id": 787246, - "is_public": true, - "modified": "2017-09-07T13:21:44.911000+00:00", - "note": "The browser's \"autoscrolling\" feature now uses asynchronous scrolling, similar to other input methods like mousewheel, providing a smoother scrolling experience.", - "sort_num": 0, - "tag": "Fixed" - } - ], - "product": "Firefox", - "release_date": "2017-08-02", - "slug": "firefox-57.0a1-nightly", - "system_requirements": "## Windows\r\n\r\n### Operating Systems (32-bit and 64-bit)\r\n - Windows 7\r\n - Windows 8\r\n - Windows 10\r\n\r\n### Recommended Hardware\r\n- Pentium 4 or newer processor that supports SSE2\r\n- 512MB of RAM / 2GB of RAM for the 64-bit version\r\n- 200MB of hard drive space\r\n\r\n---\r\n\r\n## Mac\r\n\r\n### Operating Systems\r\n- macOS 10.9\r\n- macOS 10.10\r\n- macOS 10.11\r\n- macOS 10.12\r\n- macOS 10.13\r\n\r\n### Recommended Hardware\r\n- Macintosh computer with an Intel x86 processor\r\n- 512 MB of RAM\r\n- 200 MB hard drive space\r\n\r\n---\r\n\r\n## GNU/Linux\r\n\r\n### Software Requirements\r\n\r\n*Please note that GNU/Linux distributors may provide packages for your distribution which have different requirements.*\r\n\r\n- Firefox will not run at all without the following libraries or packages:\r\n - GTK+ 3.4 or higher\r\n - GLib 2.22 or higher\r\n - Pango 1.14 or higher\r\n - X.Org 1.0 or higher (1.7 or higher is recommended)\r\n - libstdc++ 4.6.1 or higher\r\n- For optimal functionality, we recommend the following libraries or packages:\r\n - NetworkManager 0.7 or higher\r\n - DBus 1.0 or higher\r\n - GNOME 2.16 or higher\r\n - PulseAudio\r\n\r\n [1]: https://support.mozilla.org/kb/get-latest-version-firefox-windows-xp-vista", - "text": "Firefox Nightly gets a new version every day and as a consequence, the release notes for the Nightly channel are updated continuously to reflect features that have reached sufficient maturity to benefit from community feedback and bug reports. Features listed here may or may not make a final release of Firefox.\r\n\r\nIn addition to these release notes, you can follow ongoing development on our [@FirefoxNightly][1] Twitter account as well as read our [Nightly Blog][2].\r\n\r\n[1]: https://twitter.com/FirefoxNightly\r\n[2]: https://blog.nightly.mozilla.org", - "title": "Firefox 57.0a1 Nightly", - "version": "57.0a1" -} diff --git a/springfield/releasenotes/tests/releases/firefox-58.0a1-nightly.json b/springfield/releasenotes/tests/releases/firefox-58.0a1-nightly.json deleted file mode 100644 index d1dd92115..000000000 --- a/springfield/releasenotes/tests/releases/firefox-58.0a1-nightly.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "product": "Firefox", - "channel": "Nightly", - "version": "58.0a1", - "release_date": "2017-09-21", - "created": "2017-03-21T13:19:13.668000+00:00", - "modified": "2017-03-21T13:19:13.668000+00:00", - "is_public": false -} diff --git a/springfield/releasenotes/tests/releases/firefox-copy-56.0-release.json b/springfield/releasenotes/tests/releases/firefox-copy-56.0-release.json deleted file mode 100644 index 47684d608..000000000 --- a/springfield/releasenotes/tests/releases/firefox-copy-56.0-release.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "product": "Firefox", - "channel": "Release", - "version": "copy-56.0", - "release_date": "2017-08-02", - "created": "2017-03-21T13:19:13.668000+00:00", - "modified": "2017-03-21T13:19:13.668000+00:00", - "is_public": false -} diff --git a/springfield/releasenotes/tests/releases/firefox-for-android-56.0-release.json b/springfield/releasenotes/tests/releases/firefox-for-android-56.0-release.json deleted file mode 100644 index a49f52624..000000000 --- a/springfield/releasenotes/tests/releases/firefox-for-android-56.0-release.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "product": "Firefox for Android", - "channel": "Release", - "version": "56.0", - "release_date": "2017-08-02", - "created": "2017-03-21T13:19:13.668000+00:00", - "modified": "2017-03-21T13:19:13.668000+00:00", - "is_public": true -} diff --git a/springfield/releasenotes/tests/releases/firefox-for-android-56.0.1-release.json b/springfield/releasenotes/tests/releases/firefox-for-android-56.0.1-release.json deleted file mode 100644 index c7ac1bdbb..000000000 --- a/springfield/releasenotes/tests/releases/firefox-for-android-56.0.1-release.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "product": "Firefox for Android", - "channel": "Release", - "version": "56.0.1", - "release_date": "2017-08-03", - "created": "2017-03-21T13:19:13.668000+00:00", - "modified": "2017-03-21T13:19:13.668000+00:00", - "is_public": true -} diff --git a/springfield/releasenotes/tests/releases/firefox-for-android-56.0.2-release.json b/springfield/releasenotes/tests/releases/firefox-for-android-56.0.2-release.json deleted file mode 100644 index fea519e36..000000000 --- a/springfield/releasenotes/tests/releases/firefox-for-android-56.0.2-release.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "product": "Firefox for Android", - "channel": "Release", - "version": "56.0.2", - "release_date": "2017-08-04", - "created": "2017-03-21T13:19:13.668000+00:00", - "modified": "2017-03-21T13:19:13.668000+00:00", - "is_public": true -} diff --git a/springfield/releasenotes/tests/releases/firefox-for-android-56.0.3-release.json b/springfield/releasenotes/tests/releases/firefox-for-android-56.0.3-release.json deleted file mode 100644 index cfcebabe9..000000000 --- a/springfield/releasenotes/tests/releases/firefox-for-android-56.0.3-release.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "product": "Firefox for Android", - "channel": "Release", - "version": "56.0.3", - "release_date": "2017-08-05", - "created": "2017-03-21T13:19:13.668000+00:00", - "modified": "2017-03-21T13:19:13.668000+00:00", - "is_public": false -} diff --git a/springfield/releasenotes/tests/releases/firefox-for-android-58.0a1-nightly.json b/springfield/releasenotes/tests/releases/firefox-for-android-58.0a1-nightly.json deleted file mode 100644 index 67a2bdb9b..000000000 --- a/springfield/releasenotes/tests/releases/firefox-for-android-58.0a1-nightly.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "product": "Firefox for Android", - "channel": "Nightly", - "version": "58.0a1", - "release_date": "2017-09-21", - "created": "2017-03-21T13:19:13.668000+00:00", - "modified": "2017-03-21T13:19:13.668000+00:00", - "is_public": false -} diff --git a/springfield/releasenotes/urls.py b/springfield/releasenotes/urls.py new file mode 100644 index 000000000..2ec60289b --- /dev/null +++ b/springfield/releasenotes/urls.py @@ -0,0 +1,19 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +from django.urls import include, path + +from rest_framework import routers + +from . import views + +router = routers.DefaultRouter() +router.register("notes", views.NoteViewSet) +router.register("releases", views.ReleaseViewSet) + +urlpatterns = [ + path("", include(router.urls)), + path("releases//notes/", views.NestedNoteView.as_view()), + path("auth_token/", views.auth_token), + path("all-releases.json", views.export_json), +] diff --git a/springfield/releasenotes/utils.py b/springfield/releasenotes/utils.py index 6f091676c..40141eeb0 100644 --- a/springfield/releasenotes/utils.py +++ b/springfield/releasenotes/utils.py @@ -1,42 +1,49 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at https://mozilla.org/MPL/2.0/. +import json -from django.conf import settings -from django.core.cache import caches +from django.core.exceptions import ObjectDoesNotExist +from django.http import HttpResponse -from memoize import Memoizer +from .models import Note, Release -from springfield.utils.git import GitRepo +def get_last_modified_date(*args, **kwargs): + """Returns the date of the last modified Note or Release. -def get_data_version(): - """Add the git ref from the repo to the cache keys. - - This will ensure that the cache is invalidated when the repo is updated. + For use with Django's last_modified decorator. """ - repo = GitRepo(settings.RELEASE_NOTES_PATH, settings.RELEASE_NOTES_REPO, branch_name=settings.RELEASE_NOTES_BRANCH) - git_ref = repo.get_db_latest() - if git_ref is None: - git_ref = "default" - - return git_ref - - -class ReleaseMemoizer(Memoizer): - """A memoizer class that uses the git hash as the version""" - - def __init__(self, version_timeout=300): - self.version_timeout = version_timeout - return super().__init__(cache=caches["release-notes"]) - - def _memoize_make_version_hash(self): - return get_data_version() - - def _memoize_version(self, f, args=None, reset=False, delete=False, timeout=None): - """Use a shorter timeout for the version so that we can refresh based on git hash""" - return super()._memoize_version(f, args, reset, delete, self.version_timeout) - - -memoizer = ReleaseMemoizer() -memoize = memoizer.memoize + try: + latest_note = Note.objects.latest() + latest_release = Release.objects.latest() + except ObjectDoesNotExist: + return None + + return max(latest_note.modified, latest_release.modified) + + +def migrate_versions(): + for r in Release.objects.filter(version__endswith=".0.0").only("channel", "version"): + if r.channel == "Release": + Release.objects.filter(id=r.id).update(version=r.version[:-2]) + elif r.channel == "Beta": + Release.objects.filter(id=r.id).update(version=r.version[:-2] + "beta") + + +def get_duplicate_product_versions(): + version_ids = {} + duplicates = {} + for product in Release.PRODUCTS: + version_ids[product] = {} + for r in Release.objects.filter(product=product): + version_ids[product].setdefault(r.version, []) + version_ids[product][r.version].append(r.id) + if len(version_ids[product][r.version]) > 1: + duplicates[(product, r.version)] = version_ids[product][r.version] + return duplicates + + +class HttpResponseJSON(HttpResponse): + def __init__(self, data, status=None, cors=False): + super().__init__(content=json.dumps(data), content_type="application/json", status=status) + + if cors: + self["Access-Control-Allow-Origin"] = "*" diff --git a/springfield/releasenotes/views.py b/springfield/releasenotes/views.py index 3096dcf4e..9b46caa8b 100644 --- a/springfield/releasenotes/views.py +++ b/springfield/releasenotes/views.py @@ -1,30 +1,45 @@ # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. +import datetime +import json import re from copy import copy from operator import attrgetter from django.conf import settings -from django.http import Http404, HttpResponseRedirect +from django.http import Http404, HttpResponse, HttpResponseForbidden, HttpResponseRedirect +from django.shortcuts import get_object_or_404 from django.urls import NoReverseMatch -from django.views.decorators.http import require_safe +from django.utils.http import parse_http_date_safe +from django.utils.timezone import now +from django.views.decorators.cache import cache_page +from django.views.decorators.http import last_modified, require_safe + +from rest_framework import generics +from rest_framework.authtoken.models import Token +from rest_framework.viewsets import ModelViewSet from lib import l10n_utils from springfield.base.urlresolvers import reverse from springfield.firefox.firefox_details import firefox_desktop from springfield.firefox.templatetags.helpers import android_builds, ios_builds from springfield.releasenotes.models import ( + Release, + Note, get_latest_release_or_404, get_release_or_404, get_releases_or_404, ) +from springfield.releasenotes.serializers import NoteSerializer, ReleaseSerializer +from springfield.releasenotes.utils import HttpResponseJSON, get_last_modified_date SUPPORT_URLS = { "Firefox for Android": "https://support.mozilla.org/products/mobile", "Firefox for iOS": "https://support.mozilla.org/products/ios", "Firefox": "https://support.mozilla.org/products/firefox", } +RNA_JSON_CACHE_TIME = getattr(settings, "RNA_JSON_CACHE_TIME", 600) def release_notes_template(channel, product, version=None): @@ -219,3 +234,46 @@ def nightly_feed(request): notes = sorted(notes.values(), key=attrgetter("modified"), reverse=True) return l10n_utils.render(request, "firefox/releases/nightly-feed.xml", {"notes": notes}, content_type="application/atom+xml") + + +def auth_token(request): + if request.user.is_active and request.user.is_staff: + token, created = Token.objects.get_or_create(user=request.user) + return HttpResponse(content=json.dumps({"token": token.key}), content_type="application/json") + else: + return HttpResponseForbidden() + + +class NoteViewSet(ModelViewSet): + queryset = Note.objects.all() + serializer_class = NoteSerializer + + +class ReleaseViewSet(ModelViewSet): + queryset = Release.objects.all() + serializer_class = ReleaseSerializer + + +class NestedNoteView(generics.ListAPIView): + model = Note + serializer_class = NoteSerializer + + def get_queryset(self): + release = get_object_or_404(Release, pk=self.kwargs.get("pk")) + return release.note_set.all() + + +@cache_page(RNA_JSON_CACHE_TIME) +@last_modified(get_last_modified_date) +@require_safe +def export_json(request): + if request.GET.get("all") == "true": + return HttpResponseJSON(Release.objects.all_as_list(), cors=True) + + mod_date = parse_http_date_safe(request.headers.get("If-Modified-Since")) + if mod_date: + mod_date = datetime.datetime.fromtimestamp(mod_date, datetime.UTC) + else: + mod_date = now() - datetime.timedelta(days=30) + + return HttpResponseJSON(Release.objects.recently_modified_list(mod_date=mod_date), cors=True) diff --git a/springfield/rna/__init__.py b/springfield/rna/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/springfield/rna/tests/__init__.py b/springfield/rna/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/springfield/rna/tests/test_models.py b/springfield/rna/tests/test_models.py new file mode 100644 index 000000000..9e9e06794 --- /dev/null +++ b/springfield/rna/tests/test_models.py @@ -0,0 +1,137 @@ +from datetime import timedelta + +from django.test import TestCase +from django.utils.timezone import now + +from nucleus.rna.models import Country, Note, Release + + +class TestReleaseQueries(TestCase): + def setUp(self): + one_week_ago = now() - timedelta(days=7) + two_weeks_ago = now() - timedelta(days=14) + self.r1 = Release.objects.create( + product="Firefox", + channel="Nightly", + version="87.0a2", + release_date=now(), + ) + self.r2 = Release.objects.create( + product="Firefox", + channel="Nightly", + version="88.0a2", + release_date=now(), + ) + # modified now + self.r3 = Release.objects.create( + product="Firefox", + channel="Nightly", + version="89.0a2", + release_date=now(), + ) + self.r1.modified = two_weeks_ago + self.r1.save(modified=False) + self.r2.modified = one_week_ago + self.r2.save(modified=False) + + def test_recently_modified_release(self): + """Should only return releases modified more recently than `days_ago`""" + data = Release.objects.recently_modified_list(days_ago=5) + versions = [o["version"] for o in data] + assert self.r1.version not in versions + assert self.r2.version not in versions + assert self.r3.version in versions + + def test_recently_modified_note(self): + """Should also return releases with notes modified more recently than `days_ago`""" + self.r1.note_set.create(note="The Dude minds, man") + data = Release.objects.recently_modified_list(days_ago=5) + versions = [o["version"] for o in data] + assert self.r1.version in versions + assert self.r2.version not in versions + assert self.r3.version in versions + + def test_recently_modified_fixed_in_note(self): + """Should also return releases with notes modified more recently than `days_ago`""" + self.r2.fixed_note_set.create(note="The Dude minds, man") + data = Release.objects.recently_modified_list(days_ago=5) + versions = [o["version"] for o in data] + assert self.r1.version not in versions + assert self.r2.version in versions + assert self.r3.version in versions + + def test_distinct_recently_modified(self): + note = Note.objects.create(note="The Dude minds, man") + note2 = Note.objects.create(note="Careful, man, there’s a beverage here.") + self.r3.note_set.add(note) + self.r2.note_set.add(note) + self.r3.note_set.add(note2) + data = Release.objects.recently_modified_list(days_ago=5) + versions = [o["version"] for o in data] + assert self.r1.version not in versions + assert self.r2.version in versions + assert self.r3.version in versions + assert len(versions) == 2 + + +class TestNote(TestCase): + def test_to_dict__simple__no_relations(self): + # Very simple test of the to_dict() method, mainly + # to prove that progressive_rollout is included + data = dict( + bug=1234, + note="Test note", + tag="this is a tag", + sort_num=1, + is_public=True, + progressive_rollout=True, + ) + + note = Note(**data) + note.save() + + dumped_dict = note.to_dict() + for key in [ + "bug", + "note", + "tag", + "sort_num", + "is_public", + "progressive_rollout", + ]: + assert dumped_dict[key] == data[key] + + assert "created" in dumped_dict + assert "modified" in dumped_dict + + def test_to_dict__relevant_country_field(self): + # Country data is bootstrapped by a data migration + + iceland = Country.objects.get(code="IS") + india = Country.objects.get(code="IN") + + data = dict( + bug=1234, + note="Test note", + tag="this is a tag", + sort_num=1, + is_public=True, + progressive_rollout=True, + ) + + note = Note(**data) + note.save() + + assert note.relevant_countries.count() == 0 + dumped_dict = note.to_dict() + assert dumped_dict["relevant_countries"] == [] + + note.relevant_countries.add(india) + note.relevant_countries.add(iceland) + assert note.relevant_countries.count() == 2 + + dumped_dict = note.to_dict() + assert dumped_dict["relevant_countries"] == [ + {"name": "Iceland", "code": "IS"}, + {"name": "India", "code": "IN"}, + ] diff --git a/springfield/rna/tests/test_views.py b/springfield/rna/tests/test_views.py new file mode 100644 index 000000000..e0921751f --- /dev/null +++ b/springfield/rna/tests/test_views.py @@ -0,0 +1,127 @@ +import json +from datetime import timedelta + +from django.test import RequestFactory, TestCase +from django.utils.http import http_date +from django.utils.timezone import now + +from nucleus.rna.models import Note, Release +from nucleus.rna.views import export_json + + +class TestExportJSON(TestCase): + def setUp(self): + over_20_days = now() - timedelta(days=22) + over_30_days = now() - timedelta(days=32) + over_40_days = now() - timedelta(days=42) + self.r1 = Release.objects.create( + product="Firefox", + channel="Nightly", + version="87.0a2", + release_date=now(), + ) + self.r2 = Release.objects.create( + product="Firefox", + channel="Nightly", + version="88.0a2", + release_date=now(), + ) + # modified now + self.r3 = Release.objects.create( + product="Firefox", + channel="Nightly", + version="89.0a2", + release_date=now(), + ) + self.r1.modified = over_40_days + self.r1.save(modified=False) + self.r2.modified = over_30_days + self.r2.save(modified=False) + self.r3.modified = over_20_days + self.r3.save(modified=False) + self.rf = RequestFactory() + + def test_recently_modified_release(self): + """Should only return releases modified more recently than 30 days ago""" + resp = export_json(self.rf.get("/")) + data = json.loads(resp.content) + release_versions = [o["version"] for o in data] + assert self.r1.version not in release_versions + assert self.r2.version not in release_versions + assert self.r3.version in release_versions + + def test_if_modified_since(self): + """Should only return releases modified more recently than last-modified param""" + over_30_days_ago = now() - timedelta(days=34) + header_date = http_date(over_30_days_ago.timestamp()) + resp = export_json(self.rf.get("/", HTTP_IF_MODIFIED_SINCE=header_date)) + data = json.loads(resp.content) + release_versions = [o["version"] for o in data] + assert self.r1.version not in release_versions + assert self.r2.version in release_versions + assert self.r3.version in release_versions + + def test_if_modified_since_not_modified(self): + """Should return an empty list if nothing has been modified since""" + header_date = http_date(now().timestamp()) + resp = export_json(self.rf.get("/", HTTP_IF_MODIFIED_SINCE=header_date)) + data = json.loads(resp.content) + assert data == [] + + def test_invalid_if_modified_since_header(self): + """Should go back to 14 day default if param is not ISO date""" + header_date = "this is our concern Dude" + resp = export_json(self.rf.get("/", HTTP_IF_MODIFIED_SINCE=header_date)) + data = json.loads(resp.content) + release_versions = [o["version"] for o in data] + assert self.r1.version not in release_versions + assert self.r2.version not in release_versions + assert self.r3.version in release_versions + + def test_recently_modified_note(self): + """Should also return releases with notes modified more recently than 14 days ago""" + self.r1.note_set.create(note="The Dude minds, man") + resp = export_json(self.rf.get("/")) + data = json.loads(resp.content) + release_versions = [o["version"] for o in data] + assert self.r1.version in release_versions + assert self.r2.version not in release_versions + assert self.r3.version in release_versions + + def test_recently_modified_note_distinct(self): + """Should also return releases with notes modified more recently than 14 days ago""" + note = Note.objects.create(note="The Dude minds, man") + note2 = Note.objects.create(note="Careful, man, there’s a beverage here.") + self.r1.note_set.add(note) + self.r1.note_set.add(note2) + resp = export_json(self.rf.get("/")) + data = json.loads(resp.content) + release_versions = [o["version"] for o in data] + assert self.r1.version in release_versions + assert self.r2.version not in release_versions + assert self.r3.version in release_versions + assert len(release_versions) == 2 + + def test_recently_modified_fixed_in_note(self): + """Should also return releases with notes modified more recently than 14 days ago""" + self.r2.fixed_note_set.create(note="The Dude minds, man") + resp = export_json(self.rf.get("/")) + data = json.loads(resp.content) + release_versions = [o["version"] for o in data] + assert self.r1.version not in release_versions + assert self.r2.version in release_versions + assert self.r3.version in release_versions + + def test_all_as_list(self): + """Should return all releases with no dupes""" + note = Note.objects.create(note="The Dude minds, man") + note2 = Note.objects.create(note="Careful, man, there’s a beverage here.") + self.r1.note_set.add(note) + self.r1.note_set.add(note2) + resp = export_json(self.rf.get("/", {"all": "true"})) + data = json.loads(resp.content) + release_versions = [o["version"] for o in data] + assert self.r1.version in release_versions + assert self.r2.version in release_versions + assert self.r3.version in release_versions + assert len(release_versions) == 3 diff --git a/springfield/rna/views.py b/springfield/rna/views.py new file mode 100644 index 000000000..cdbc22a1c --- /dev/null +++ b/springfield/rna/views.py @@ -0,0 +1,67 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import datetime +import json + +from django.conf import settings +from django.http import HttpResponse, HttpResponseForbidden +from django.shortcuts import get_object_or_404 +from django.utils.http import parse_http_date_safe +from django.utils.timezone import now +from django.views.decorators.cache import cache_page +from django.views.decorators.http import last_modified, require_safe + +from rest_framework import generics +from rest_framework.authtoken.models import Token +from rest_framework.viewsets import ModelViewSet + +from nucleus.rna.models import Note, Release +from nucleus.rna.serializers import NoteSerializer, ReleaseSerializer +from nucleus.rna.utils import HttpResponseJSON, get_last_modified_date + +RNA_JSON_CACHE_TIME = getattr(settings, "RNA_JSON_CACHE_TIME", 600) + + +def auth_token(request): + if request.user.is_active and request.user.is_staff: + token, created = Token.objects.get_or_create(user=request.user) + return HttpResponse(content=json.dumps({"token": token.key}), content_type="application/json") + else: + return HttpResponseForbidden() + + +class NoteViewSet(ModelViewSet): + queryset = Note.objects.all() + serializer_class = NoteSerializer + + +class ReleaseViewSet(ModelViewSet): + queryset = Release.objects.all() + serializer_class = ReleaseSerializer + + +class NestedNoteView(generics.ListAPIView): + model = Note + serializer_class = NoteSerializer + + def get_queryset(self): + release = get_object_or_404(Release, pk=self.kwargs.get("pk")) + return release.note_set.all() + + +@cache_page(RNA_JSON_CACHE_TIME) +@last_modified(get_last_modified_date) +@require_safe +def export_json(request): + if request.GET.get("all") == "true": + return HttpResponseJSON(Release.objects.all_as_list(), cors=True) + + mod_date = parse_http_date_safe(request.headers.get("If-Modified-Since")) + if mod_date: + mod_date = datetime.datetime.fromtimestamp(mod_date, datetime.UTC) + else: + mod_date = now() - datetime.timedelta(days=30) + + return HttpResponseJSON(Release.objects.recently_modified_list(mod_date=mod_date), cors=True) diff --git a/springfield/settings/base.py b/springfield/settings/base.py index c5f4cc094..8611e7de2 100644 --- a/springfield/settings/base.py +++ b/springfield/settings/base.py @@ -745,8 +745,24 @@ def get_app_name(hostname): "watchman.checks.databases", ) +REST_FRAMEWORK = { + # Use hyperlinked styles by default. + # Only used if the `serializer_class` attribute is not set on a view. + "DEFAULT_MODEL_SERIALIZER_CLASS": "springfield.releasenotes.serializers.HyperlinkedModelSerializerWithPkField", + # Use Django's standard `django.contrib.auth` permissions, + # or allow read-only access for unauthenticated users. + "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly",), + "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",), +} + +DJANGO_TEMPLATE_CUSTOM_APPS = [ + "springfield.releasenotes" +] def _is_springfield_custom_app(app_name): + if app_name in DJANGO_TEMPLATE_CUSTOM_APPS: + return False + return app_name.startswith("springfield.") @@ -785,17 +801,21 @@ def _is_springfield_custom_app(app_name): }, }, { - # Wagtail needs the standard Django template backend + # Wagtail and Django admin needs the standard Django template backend # https://docs.wagtail.org/en/stable/reference/jinja2.html#configuring-django "BACKEND": "django.template.backends.django.DjangoTemplates", "APP_DIRS": True, "DIRS": [ "springfield/admin/templates", + "springfield/releasenotes/templates", ], "OPTIONS": { "context_processors": [ "django.template.context_processors.debug", + "django.template.context_processors.media", "django.template.context_processors.request", + "django.template.context_processors.static", + "django.template.context_processors.tz", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "wagtail.contrib.settings.context_processors.settings", @@ -1172,6 +1192,9 @@ def before_send(event, hint): "django.contrib.admin", # wagtail_localize_smartling > 0.10 needs django.contrib.humanize "django.contrib.humanize", + "pagedown.apps.PagedownConfig", + "rest_framework", + "rest_framework.authtoken", ] for midddleware_spec in [ @@ -1179,6 +1202,7 @@ def before_send(event, hint): "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", + "springfield.releasenotes.middleware.PatchOverrideMiddleware", ]: MIDDLEWARE.insert(3, midddleware_spec) @@ -1189,6 +1213,7 @@ def before_send(event, hint): "django-rq", "oidc", "_internal_draft_preview", + "releasenotes-admin", ] ) diff --git a/springfield/urls.py b/springfield/urls.py index 359dc3c88..8e6e5a511 100644 --- a/springfield/urls.py +++ b/springfield/urls.py @@ -8,6 +8,7 @@ from django.utils.module_loading import import_string import wagtaildraftsharing.urls as wagtaildraftsharing_urls +from rest_framework.authtoken.views import obtain_auth_token from wagtail import urls as wagtail_urls from wagtail.admin import urls as wagtailadmin_urls from wagtail.documents import urls as wagtaildocs_urls @@ -62,6 +63,8 @@ path("django-admin/", admin.site.urls), # needed to show django-rq UI path("django-rq/", include("django_rq.urls")), # task queue management path("_internal_draft_preview/", include(wagtaildraftsharing_urls)), # ONLY available in CMS mode + path("api-token-auth/", obtain_auth_token), + path("releasenotes-admin/", include("springfield.releasenotes.urls")), ) if settings.ENABLE_DJANGO_SILK: From 0f6456140028fbfa13cf55fd94c453fa5efb200a Mon Sep 17 00:00:00 2001 From: Paul McLanahan Date: Thu, 22 May 2025 15:54:08 -0400 Subject: [PATCH 2/3] Remove old ProductRelease model --- .../migrations/0005_delete_productrelease.py | 16 ++ springfield/releasenotes/models.py | 193 ------------------ 2 files changed, 16 insertions(+), 193 deletions(-) create mode 100644 springfield/releasenotes/migrations/0005_delete_productrelease.py diff --git a/springfield/releasenotes/migrations/0005_delete_productrelease.py b/springfield/releasenotes/migrations/0005_delete_productrelease.py new file mode 100644 index 000000000..dd92eca11 --- /dev/null +++ b/springfield/releasenotes/migrations/0005_delete_productrelease.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2 on 2025-05-22 19:53 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('releasenotes', '0004_data_bootstrap_countries'), + ] + + operations = [ + migrations.DeleteModel( + name='ProductRelease', + ), + ] diff --git a/springfield/releasenotes/models.py b/springfield/releasenotes/models.py index 90b5c5b2b..6e278fc05 100644 --- a/springfield/releasenotes/models.py +++ b/springfield/releasenotes/models.py @@ -108,41 +108,6 @@ def process_markdown(value): return bleach.clean(rendered_html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS) -def process_notes(notes): - notes = [Note(d) for d in notes] - return [n for n in notes if n.is_public] - - -def process_is_public(is_public): - if settings.DEV: - return True - - return is_public - - -def process_note_release(rel_data): - return ProductRelease(**rel_data) - - -FIELD_PROCESSORS = { - "created": parse_datetime, - "modified": parse_datetime, - "is_public": process_is_public, - "note": process_markdown, - "fixed_in_release": process_note_release, -} - - -class RNModel: - def __init__(self, data): - for key, value in data.items(): - if not hasattr(self, key): - continue - if key in FIELD_PROCESSORS: - value = FIELD_PROCESSORS[key](value) - setattr(self, key, value) - - class MarkdownField(models.TextField): """Field that takes Markdown text as input and saves HTML to the database""" @@ -153,164 +118,6 @@ def pre_save(self, model_instance, add): return value -class ProductReleaseQuerySet(models.QuerySet): - def product(self, product_name, channel_name=None, version=None): - if product_name.lower() == "firefox extended support release": - product_name = "firefox" - channel_name = "esr" - q = self.filter(product__iexact=product_name) - if channel_name: - q = q.filter(channel__iexact=channel_name) - if version: - q = q.filter(version=version) - - return q - - -class ProductReleaseManager(models.Manager): - def get_queryset(self, include_drafts=False): - qs = ProductReleaseQuerySet(self.model, using=self._db) - if settings.DEV or include_drafts: - return qs - - return qs.filter(is_public=True) - - def product(self, product_name, channel_name=None, version=None, include_drafts=False): - return self.get_queryset(include_drafts).product(product_name, channel_name, version) - - def refresh(self): - version_regex = re.compile(version_re) - release_objs = [] - rn_path = os.path.join(settings.RELEASE_NOTES_PATH, "releases") - with transaction.atomic(using=self.db): - self.get_queryset(include_drafts=True).delete() - releases = glob(os.path.join(rn_path, "*.json")) - for release_file in releases: - with codecs.open(release_file, "r", encoding="utf-8") as rel_fh: - data = json.load(rel_fh) - # Make sure the version is valid and publicly accessible. - if not version_regex.match(data["version"]): - continue - # doing this to simplify queries for Firefox since it is always - # looked up with product=Firefox and relies on the version number - # and channel to determine ESR. - if data["product"] == "Firefox Extended Support Release": - data["product"] = "Firefox" - data["channel"] = "ESR" - # make all releases public on non-production environments - if settings.DEV: - data["is_public"] = True - release_objs.append(ProductRelease(**data)) - - self.bulk_create(release_objs) - - return len(release_objs) - - -class ProductRelease(models.Model): - CHANNELS = ("Nightly", "Aurora", "Beta", "Release", "ESR") - PRODUCTS = ("Firefox", "Firefox for Android", "Firefox Extended Support Release", "Firefox OS", "Thunderbird", "Firefox for iOS") - - product = models.CharField(max_length=50) - channel = models.CharField(max_length=50) - version = models.CharField(max_length=25) - slug = models.CharField(max_length=255) - title = models.CharField(max_length=255) - release_date = models.DateField() - text = MarkdownField(blank=True) - is_public = models.BooleanField(default=False) - bug_list = models.TextField(blank=True) - bug_search_url = models.CharField(max_length=2000, blank=True) - system_requirements = MarkdownField(blank=True) - created = models.DateTimeField() - modified = models.DateTimeField() - notes = JSONField(blank=True) - - objects = ProductReleaseManager() - - class Meta: - ordering = ["-release_date"] - - def __str__(self): - return self.title - - def get_absolute_url(self): - if self.product == "Firefox for Android": - urlname = "firefox.android.releasenotes" - elif self.product == "Firefox for iOS": - urlname = "firefox.ios.releasenotes" - else: - urlname = "firefox.desktop.releasenotes" - - prefix = "aurora" if self.channel == "Aurora" else "release" - return reverse(urlname, args=[self.version, prefix]) - - @cached_property - def major_version(self): - return str(self.version_obj.major) - - @cached_property - def major_version_int(self): - return self.version_obj.major or 0 - - @cached_property - def version_obj(self): - return Version(self.version) - - @property - def is_latest(self): - return self == get_latest_release(self.product, self.channel) - - def get_sysreq_url(self): - if self.product == "Firefox for Android": - urlname = "firefox.android.system_requirements" - elif self.product == "Firefox for iOS": - urlname = "firefox.ios.system_requirements" - else: - urlname = "firefox.system_requirements" - - return reverse(urlname, args=[self.version]) - - def get_bug_search_url(self): - if self.bug_search_url: - return self.bug_search_url - return ( - "https://bugzilla.mozilla.org/buglist.cgi?" - "j_top=OR&f1=target_milestone&o3=equals&v3=Firefox%20{version}&" - "o1=equals&resolution=FIXED&o2=anyexact&query_format=advanced&" - "f3=target_milestone&f2=cf_status_firefox{version}&" - "bug_status=RESOLVED&bug_status=VERIFIED&bug_status=CLOSED&" - "v1=mozilla{version}&v2=fixed%2Cverified&limit=0" - ).format(version=self.major_version) - - def equivalent_release_for_product(self, product): - """ - Returns the release for a specified product with the same - channel and major version with the highest minor version, - or None if no such releases exist - """ - releases = ProductRelease.objects.product(product, self.channel).filter(version__startswith=f"{self.major_version}.") - if releases: - return sorted(releases, reverse=True, key=attrgetter("version_obj"))[0] - - return None - - def equivalent_android_release(self): - if self.product == "Firefox": - return self.equivalent_release_for_product("Firefox for Android") - - def equivalent_desktop_release(self): - if self.product == "Firefox for Android": - return self.equivalent_release_for_product("Firefox") - - def get_notes(self): - if not self.notes: - return self.notes - return process_notes(self.notes) - - -### RNA MODELS - class ReleaseQuerySet(models.QuerySet): def product(self, product_name, channel_name=None, version=None): if product_name.lower() == "firefox extended support release": From 49c92b5928bd0dc51fe3d05661681d186f5f1608 Mon Sep 17 00:00:00 2001 From: Paul McLanahan Date: Fri, 30 May 2025 12:38:40 -0400 Subject: [PATCH 3/3] Merge the relevant old tests from the rna app into releasenotes --- springfield/releasenotes/models.py | 11 + springfield/releasenotes/tests/test_base.py | 3 +- springfield/releasenotes/tests/test_models.py | 252 ++++++++++-------- springfield/releasenotes/tests/test_views.py | 128 ++++++++- springfield/rna/__init__.py | 0 springfield/rna/tests/__init__.py | 0 springfield/rna/tests/test_models.py | 137 ---------- springfield/rna/tests/test_views.py | 127 --------- springfield/rna/views.py | 67 ----- 9 files changed, 279 insertions(+), 446 deletions(-) delete mode 100644 springfield/rna/__init__.py delete mode 100644 springfield/rna/tests/__init__.py delete mode 100644 springfield/rna/tests/test_models.py delete mode 100644 springfield/rna/tests/test_views.py delete mode 100644 springfield/rna/views.py diff --git a/springfield/releasenotes/models.py b/springfield/releasenotes/models.py index 6e278fc05..4307cd326 100644 --- a/springfield/releasenotes/models.py +++ b/springfield/releasenotes/models.py @@ -176,6 +176,17 @@ class Release(TimeStampedModel): objects = ReleaseManager() + def get_absolute_url(self): + if self.product == "Firefox for Android": + urlname = "firefox.android.releasenotes" + elif self.product == "Firefox for iOS": + urlname = "firefox.ios.releasenotes" + else: + urlname = "firefox.desktop.releasenotes" + + prefix = "aurora" if self.channel == "Aurora" else "release" + return reverse(urlname, args=[self.version, prefix]) + @property def slug(self): product = slugify(self.product) diff --git a/springfield/releasenotes/tests/test_base.py b/springfield/releasenotes/tests/test_base.py index e50005b93..ae53e114c 100644 --- a/springfield/releasenotes/tests/test_base.py +++ b/springfield/releasenotes/tests/test_base.py @@ -30,7 +30,8 @@ class TestReleaseViews(TestCase): # get_release_or_404 @override_settings(DEV=False) def setUp(self): - ProductRelease.objects.refresh() + # To get these tests to work we need to load test data, but this method no longer works + # ProductRelease.objects.refresh() caches["release-notes"].clear() self.activate_locale("en-US") self.factory = RequestFactory() diff --git a/springfield/releasenotes/tests/test_models.py b/springfield/releasenotes/tests/test_models.py index b476760e3..e4a5f7b0f 100644 --- a/springfield/releasenotes/tests/test_models.py +++ b/springfield/releasenotes/tests/test_models.py @@ -25,7 +25,7 @@ def test_aurora_android_releasenotes_url(self, mock_reverse): """ Should return the results of reverse with the correct args """ - release = models.ProductRelease(channel="Aurora", version="42.0a2", product="Firefox for Android") + release = models.Release(channel="Aurora", version="42.0a2", product="Firefox for Android") assert release.get_absolute_url() == mock_reverse.return_value mock_reverse.assert_called_with("firefox.android.releasenotes", args=["42.0a2", "aurora"]) @@ -33,117 +33,12 @@ def test_desktop_releasenotes_url(self, mock_reverse): """ Should return the results of reverse with the correct args """ - release = models.ProductRelease(version="42.0", product="Firefox") + release = models.Release(version="42.0", product="Firefox") assert release.get_absolute_url() == mock_reverse.return_value mock_reverse.assert_called_with("firefox.desktop.releasenotes", args=["42.0", "release"]) -@override_settings(RELEASE_NOTES_PATH=RELEASES_PATH, DEV=False) -class TestReleaseModel(TestCase): - def setUp(self): - models.ProductRelease.objects.refresh() - release_cache.clear() - - def _add_in_ff100(self): - # TODO: remove once FF100 data is properly in the dataset - last = models.ProductRelease.objects.filter(channel="Nightly").last() - last.pk = None - last.version = "100.0a1" - last.title = "Firefox 100.0a1 Nightly" - last.slug = "firefox-100.0a1-nightly" - last.notes = [] - last.save() - assert last.pk is not None - release_cache.clear() - - def test_release_major_version(self): - rel = models.get_release("firefox", "57.0a1") - assert rel.major_version == "57" - - def test_release_major_version__ff100(self): - self._add_in_ff100() - rel = models.get_release("firefox", "100.0a1") - assert rel.major_version == "100" - - def test_get_bug_search_url(self): - rel = models.get_release("firefox", "57.0a1") - assert "=Firefox%2057&" in rel.get_bug_search_url() - rel.bug_search_url = "custom url" - assert "custom url" == rel.get_bug_search_url() - - def test_get_bug_search_url__ff100(self): - self._add_in_ff100() - rel = models.get_release("firefox", "100.0a1") - assert "=Firefox%20100&" in rel.get_bug_search_url() - rel.bug_search_url = "custom url" - assert "custom url" == rel.get_bug_search_url() - - def test_equivalent_release_for_product(self): - """Based on the test files the equivalent release for 56 should be 56.0.2""" - rel = models.get_release("firefox", "56.0", "release") - android = rel.equivalent_release_for_product("Firefox for Android") - assert android.version == "56.0.2" - assert android.product == "Firefox for Android" - - def test_equivalent_release_for_product_none_match(self): - rel = models.get_release("firefox", "45.0esr") - android = rel.equivalent_release_for_product("Firefox for Android") - assert android is None - - def test_note_fixed_in_release(self): - rel = models.get_release("firefox", "55.0a1") - note = rel.get_notes()[11] - with self.activate_locale("en-US"): - assert note.fixed_in_release.get_absolute_url() == "/en-US/firefox/55.0a1/releasenotes/" - - def test_field_processors(self): - rel = models.get_release("firefox", "57.0a1") - # datetime conversion - assert rel.created.year == 2017 - # datetime conversion - assert rel.modified.year == 2017 - # date conversion - assert rel.release_date.year == 2017 - # markdown - assert rel.system_requirements.startswith('

    Windows

    ') - # version - assert rel.version_obj.major == 57 - - # notes - note = rel.get_notes()[0] - # datetime conversion - assert note.created.year == 2017 - # datetime conversion - assert note.modified.year == 2017 - # markdown - assert note.note.startswith("

    Firefox Nightly") - assert note.id == 787203 - - @override_settings(DEV=False) - def test_is_public_query(self): - """Should not return the release value when DEV is false. - - Should also only include public notes.""" - assert models.get_release("firefox for android", "56.0.3") is None - rel = models.get_release("firefox", "57.0a1") - assert len(rel.get_notes()) == 4 - - @override_settings(DEV=True) - def test_is_public_field_processor_dev_true(self): - """Should always be true when DEV is true.""" - models.get_release("firefox for android", "56.0.3") - rel = models.get_release("firefox", "57.0a1") - assert len(rel.get_notes()) == 6 - - @override_settings(DEV=True) - def test_invalid_version(self): - """Should not load data for invalid versions.""" - models.ProductRelease.objects.refresh() - release_cache.clear() - assert models.get_release("firefox", "copy-56.0") is None - - -@patch.object(models.ProductRelease, "objects") +@patch.object(models.Release, "objects") class TestGetRelease(TestCase): def setUp(self): release_cache.clear() @@ -151,7 +46,7 @@ def setUp(self): def test_get_release(self, manager_mock): manager_mock.product().get.return_value = "dude is released" assert models.get_release("Firefox", "57.0") == "dude is released" - manager_mock.product.assert_called_with("Firefox", models.ProductRelease.CHANNELS[0], "57.0", False) + manager_mock.product.assert_called_with("Firefox", models.Release.CHANNELS[0], "57.0", False) def test_get_release_esr(self, manager_mock): manager_mock.product().get.return_value = "dude is released" @@ -160,17 +55,17 @@ def test_get_release_esr(self, manager_mock): def test_get_release_none_match(self, manager_mock): """Make sure the proper exception is raised if no file matches the query""" - manager_mock.product().get.side_effect = models.ProductRelease.DoesNotExist + manager_mock.product().get.side_effect = models.Release.DoesNotExist assert models.get_release("Firefox", "57.0") is None - expected_calls = chain.from_iterable((call("Firefox", ch, "57.0", False), call().get()) for ch in models.ProductRelease.CHANNELS) + expected_calls = chain.from_iterable((call("Firefox", ch, "57.0", False), call().get()) for ch in models.Release.CHANNELS) manager_mock.product.assert_has_calls(expected_calls) @override_settings(RELEASE_NOTES_PATH=RELEASES_PATH, DEV=False) class TestGetLatestRelease(TestCase): def setUp(self): - models.ProductRelease.objects.refresh() + models.Release.objects.refresh() release_cache.clear() def test_latest_release(self): @@ -179,7 +74,7 @@ def test_latest_release(self): def test_non_public_release_not_duped(self): # refresh again - models.ProductRelease.objects.refresh() + models.Release.objects.refresh() release_cache.clear() # non public release # should NOT raise multiple objects error @@ -199,3 +94,134 @@ def test_strikethrough_rendered_to_del(self): markdown.markdown("*hello~~test~~*"), "

    hello~~test~~

    ", ) + + +class TestReleaseQueries(TestCase): + def setUp(self): + one_week_ago = now() - timedelta(days=7) + two_weeks_ago = now() - timedelta(days=14) + self.r1 = models.Release.objects.create( + product="Firefox", + channel="Nightly", + version="87.0a2", + release_date=now(), + ) + self.r2 = models.Release.objects.create( + product="Firefox", + channel="Nightly", + version="88.0a2", + release_date=now(), + ) + # modified now + self.r3 = models.Release.objects.create( + product="Firefox", + channel="Nightly", + version="89.0a2", + release_date=now(), + ) + self.r1.modified = two_weeks_ago + self.r1.save(modified=False) + self.r2.modified = one_week_ago + self.r2.save(modified=False) + + def test_recently_modified_release(self): + """Should only return releases modified more recently than `days_ago`""" + data = models.Release.objects.recently_modified_list(days_ago=5) + versions = [o["version"] for o in data] + assert self.r1.version not in versions + assert self.r2.version not in versions + assert self.r3.version in versions + + def test_recently_modified_note(self): + """Should also return releases with notes modified more recently than `days_ago`""" + self.r1.note_set.create(note="The Dude minds, man") + data = models.Release.objects.recently_modified_list(days_ago=5) + versions = [o["version"] for o in data] + assert self.r1.version in versions + assert self.r2.version not in versions + assert self.r3.version in versions + + def test_recently_modified_fixed_in_note(self): + """Should also return releases with notes modified more recently than `days_ago`""" + self.r2.fixed_note_set.create(note="The Dude minds, man") + data = models.Release.objects.recently_modified_list(days_ago=5) + versions = [o["version"] for o in data] + assert self.r1.version not in versions + assert self.r2.version in versions + assert self.r3.version in versions + + def test_distinct_recently_modified(self): + note = models.Note.objects.create(note="The Dude minds, man") + note2 = models.Note.objects.create(note="Careful, man, there’s a beverage here.") + self.r3.note_set.add(note) + self.r2.note_set.add(note) + self.r3.note_set.add(note2) + data = models.Release.objects.recently_modified_list(days_ago=5) + versions = [o["version"] for o in data] + assert self.r1.version not in versions + assert self.r2.version in versions + assert self.r3.version in versions + assert len(versions) == 2 + + +class TestNote(TestCase): + def test_to_dict__simple__no_relations(self): + # Very simple test of the to_dict() method, mainly + # to prove that progressive_rollout is included + data = dict( + bug=1234, + note="Test note", + tag="this is a tag", + sort_num=1, + is_public=True, + progressive_rollout=True, + ) + + note = Note(**data) + note.save() + + dumped_dict = note.to_dict() + for key in [ + "bug", + "note", + "tag", + "sort_num", + "is_public", + "progressive_rollout", + ]: + assert dumped_dict[key] == data[key] + + assert "created" in dumped_dict + assert "modified" in dumped_dict + + def test_to_dict__relevant_country_field(self): + # Country data is bootstrapped by a data migration + + iceland = models.Country.objects.get(code="IS") + india = models.Country.objects.get(code="IN") + + data = dict( + bug=1234, + note="Test note", + tag="this is a tag", + sort_num=1, + is_public=True, + progressive_rollout=True, + ) + + note = models.Note(**data) + note.save() + + assert note.relevant_countries.count() == 0 + dumped_dict = note.to_dict() + assert dumped_dict["relevant_countries"] == [] + + note.relevant_countries.add(india) + note.relevant_countries.add(iceland) + assert note.relevant_countries.count() == 2 + + dumped_dict = note.to_dict() + assert dumped_dict["relevant_countries"] == [ + {"name": "Iceland", "code": "IS"}, + {"name": "India", "code": "IN"}, + ] diff --git a/springfield/releasenotes/tests/test_views.py b/springfield/releasenotes/tests/test_views.py index 11c1617df..e775492a9 100644 --- a/springfield/releasenotes/tests/test_views.py +++ b/springfield/releasenotes/tests/test_views.py @@ -2,11 +2,19 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import json +from datetime import timedelta from unittest.mock import patch +from django.test import RequestFactory, TestCase +from django.utils.http import http_date +from django.utils.timezone import now + import pytest -from springfield.releasenotes.views import releases_index, show_android_sys_req +from springfield.releasenotes.models import Note, Release +from springfield.releasenotes.views import releases_index, show_android_sys_req, export_json @pytest.mark.parametrize( @@ -161,3 +169,121 @@ def test_releases_index__product_other_than_firefox(render_mock, rf): "someproduct/releases/index.html", {"releases": []}, ) + + +class TestExportJSON(TestCase): + def setUp(self): + over_20_days = now() - timedelta(days=22) + over_30_days = now() - timedelta(days=32) + over_40_days = now() - timedelta(days=42) + self.r1 = Release.objects.create( + product="Firefox", + channel="Nightly", + version="87.0a2", + release_date=now(), + ) + self.r2 = Release.objects.create( + product="Firefox", + channel="Nightly", + version="88.0a2", + release_date=now(), + ) + # modified now + self.r3 = Release.objects.create( + product="Firefox", + channel="Nightly", + version="89.0a2", + release_date=now(), + ) + self.r1.modified = over_40_days + self.r1.save(modified=False) + self.r2.modified = over_30_days + self.r2.save(modified=False) + self.r3.modified = over_20_days + self.r3.save(modified=False) + self.rf = RequestFactory() + + def test_recently_modified_release(self): + """Should only return releases modified more recently than 30 days ago""" + resp = export_json(self.rf.get("/")) + data = json.loads(resp.content) + release_versions = [o["version"] for o in data] + assert self.r1.version not in release_versions + assert self.r2.version not in release_versions + assert self.r3.version in release_versions + + def test_if_modified_since(self): + """Should only return releases modified more recently than last-modified param""" + over_30_days_ago = now() - timedelta(days=34) + header_date = http_date(over_30_days_ago.timestamp()) + resp = export_json(self.rf.get("/", HTTP_IF_MODIFIED_SINCE=header_date)) + data = json.loads(resp.content) + release_versions = [o["version"] for o in data] + assert self.r1.version not in release_versions + assert self.r2.version in release_versions + assert self.r3.version in release_versions + + def test_if_modified_since_not_modified(self): + """Should return an empty list if nothing has been modified since""" + header_date = http_date(now().timestamp()) + resp = export_json(self.rf.get("/", HTTP_IF_MODIFIED_SINCE=header_date)) + data = json.loads(resp.content) + assert data == [] + + def test_invalid_if_modified_since_header(self): + """Should go back to 14 day default if param is not ISO date""" + header_date = "this is our concern Dude" + resp = export_json(self.rf.get("/", HTTP_IF_MODIFIED_SINCE=header_date)) + data = json.loads(resp.content) + release_versions = [o["version"] for o in data] + assert self.r1.version not in release_versions + assert self.r2.version not in release_versions + assert self.r3.version in release_versions + + def test_recently_modified_note(self): + """Should also return releases with notes modified more recently than 14 days ago""" + self.r1.note_set.create(note="The Dude minds, man") + resp = export_json(self.rf.get("/")) + data = json.loads(resp.content) + release_versions = [o["version"] for o in data] + assert self.r1.version in release_versions + assert self.r2.version not in release_versions + assert self.r3.version in release_versions + + def test_recently_modified_note_distinct(self): + """Should also return releases with notes modified more recently than 14 days ago""" + note = Note.objects.create(note="The Dude minds, man") + note2 = Note.objects.create(note="Careful, man, there’s a beverage here.") + self.r1.note_set.add(note) + self.r1.note_set.add(note2) + resp = export_json(self.rf.get("/")) + data = json.loads(resp.content) + release_versions = [o["version"] for o in data] + assert self.r1.version in release_versions + assert self.r2.version not in release_versions + assert self.r3.version in release_versions + assert len(release_versions) == 2 + + def test_recently_modified_fixed_in_note(self): + """Should also return releases with notes modified more recently than 14 days ago""" + self.r2.fixed_note_set.create(note="The Dude minds, man") + resp = export_json(self.rf.get("/")) + data = json.loads(resp.content) + release_versions = [o["version"] for o in data] + assert self.r1.version not in release_versions + assert self.r2.version in release_versions + assert self.r3.version in release_versions + + def test_all_as_list(self): + """Should return all releases with no dupes""" + note = Note.objects.create(note="The Dude minds, man") + note2 = Note.objects.create(note="Careful, man, there’s a beverage here.") + self.r1.note_set.add(note) + self.r1.note_set.add(note2) + resp = export_json(self.rf.get("/", {"all": "true"})) + data = json.loads(resp.content) + release_versions = [o["version"] for o in data] + assert self.r1.version in release_versions + assert self.r2.version in release_versions + assert self.r3.version in release_versions + assert len(release_versions) == 3 diff --git a/springfield/rna/__init__.py b/springfield/rna/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/springfield/rna/tests/__init__.py b/springfield/rna/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/springfield/rna/tests/test_models.py b/springfield/rna/tests/test_models.py deleted file mode 100644 index 9e9e06794..000000000 --- a/springfield/rna/tests/test_models.py +++ /dev/null @@ -1,137 +0,0 @@ -from datetime import timedelta - -from django.test import TestCase -from django.utils.timezone import now - -from nucleus.rna.models import Country, Note, Release - - -class TestReleaseQueries(TestCase): - def setUp(self): - one_week_ago = now() - timedelta(days=7) - two_weeks_ago = now() - timedelta(days=14) - self.r1 = Release.objects.create( - product="Firefox", - channel="Nightly", - version="87.0a2", - release_date=now(), - ) - self.r2 = Release.objects.create( - product="Firefox", - channel="Nightly", - version="88.0a2", - release_date=now(), - ) - # modified now - self.r3 = Release.objects.create( - product="Firefox", - channel="Nightly", - version="89.0a2", - release_date=now(), - ) - self.r1.modified = two_weeks_ago - self.r1.save(modified=False) - self.r2.modified = one_week_ago - self.r2.save(modified=False) - - def test_recently_modified_release(self): - """Should only return releases modified more recently than `days_ago`""" - data = Release.objects.recently_modified_list(days_ago=5) - versions = [o["version"] for o in data] - assert self.r1.version not in versions - assert self.r2.version not in versions - assert self.r3.version in versions - - def test_recently_modified_note(self): - """Should also return releases with notes modified more recently than `days_ago`""" - self.r1.note_set.create(note="The Dude minds, man") - data = Release.objects.recently_modified_list(days_ago=5) - versions = [o["version"] for o in data] - assert self.r1.version in versions - assert self.r2.version not in versions - assert self.r3.version in versions - - def test_recently_modified_fixed_in_note(self): - """Should also return releases with notes modified more recently than `days_ago`""" - self.r2.fixed_note_set.create(note="The Dude minds, man") - data = Release.objects.recently_modified_list(days_ago=5) - versions = [o["version"] for o in data] - assert self.r1.version not in versions - assert self.r2.version in versions - assert self.r3.version in versions - - def test_distinct_recently_modified(self): - note = Note.objects.create(note="The Dude minds, man") - note2 = Note.objects.create(note="Careful, man, there’s a beverage here.") - self.r3.note_set.add(note) - self.r2.note_set.add(note) - self.r3.note_set.add(note2) - data = Release.objects.recently_modified_list(days_ago=5) - versions = [o["version"] for o in data] - assert self.r1.version not in versions - assert self.r2.version in versions - assert self.r3.version in versions - assert len(versions) == 2 - - -class TestNote(TestCase): - def test_to_dict__simple__no_relations(self): - # Very simple test of the to_dict() method, mainly - # to prove that progressive_rollout is included - data = dict( - bug=1234, - note="Test note", - tag="this is a tag", - sort_num=1, - is_public=True, - progressive_rollout=True, - ) - - note = Note(**data) - note.save() - - dumped_dict = note.to_dict() - for key in [ - "bug", - "note", - "tag", - "sort_num", - "is_public", - "progressive_rollout", - ]: - assert dumped_dict[key] == data[key] - - assert "created" in dumped_dict - assert "modified" in dumped_dict - - def test_to_dict__relevant_country_field(self): - # Country data is bootstrapped by a data migration - - iceland = Country.objects.get(code="IS") - india = Country.objects.get(code="IN") - - data = dict( - bug=1234, - note="Test note", - tag="this is a tag", - sort_num=1, - is_public=True, - progressive_rollout=True, - ) - - note = Note(**data) - note.save() - - assert note.relevant_countries.count() == 0 - dumped_dict = note.to_dict() - assert dumped_dict["relevant_countries"] == [] - - note.relevant_countries.add(india) - note.relevant_countries.add(iceland) - assert note.relevant_countries.count() == 2 - - dumped_dict = note.to_dict() - assert dumped_dict["relevant_countries"] == [ - {"name": "Iceland", "code": "IS"}, - {"name": "India", "code": "IN"}, - ] diff --git a/springfield/rna/tests/test_views.py b/springfield/rna/tests/test_views.py deleted file mode 100644 index e0921751f..000000000 --- a/springfield/rna/tests/test_views.py +++ /dev/null @@ -1,127 +0,0 @@ -import json -from datetime import timedelta - -from django.test import RequestFactory, TestCase -from django.utils.http import http_date -from django.utils.timezone import now - -from nucleus.rna.models import Note, Release -from nucleus.rna.views import export_json - - -class TestExportJSON(TestCase): - def setUp(self): - over_20_days = now() - timedelta(days=22) - over_30_days = now() - timedelta(days=32) - over_40_days = now() - timedelta(days=42) - self.r1 = Release.objects.create( - product="Firefox", - channel="Nightly", - version="87.0a2", - release_date=now(), - ) - self.r2 = Release.objects.create( - product="Firefox", - channel="Nightly", - version="88.0a2", - release_date=now(), - ) - # modified now - self.r3 = Release.objects.create( - product="Firefox", - channel="Nightly", - version="89.0a2", - release_date=now(), - ) - self.r1.modified = over_40_days - self.r1.save(modified=False) - self.r2.modified = over_30_days - self.r2.save(modified=False) - self.r3.modified = over_20_days - self.r3.save(modified=False) - self.rf = RequestFactory() - - def test_recently_modified_release(self): - """Should only return releases modified more recently than 30 days ago""" - resp = export_json(self.rf.get("/")) - data = json.loads(resp.content) - release_versions = [o["version"] for o in data] - assert self.r1.version not in release_versions - assert self.r2.version not in release_versions - assert self.r3.version in release_versions - - def test_if_modified_since(self): - """Should only return releases modified more recently than last-modified param""" - over_30_days_ago = now() - timedelta(days=34) - header_date = http_date(over_30_days_ago.timestamp()) - resp = export_json(self.rf.get("/", HTTP_IF_MODIFIED_SINCE=header_date)) - data = json.loads(resp.content) - release_versions = [o["version"] for o in data] - assert self.r1.version not in release_versions - assert self.r2.version in release_versions - assert self.r3.version in release_versions - - def test_if_modified_since_not_modified(self): - """Should return an empty list if nothing has been modified since""" - header_date = http_date(now().timestamp()) - resp = export_json(self.rf.get("/", HTTP_IF_MODIFIED_SINCE=header_date)) - data = json.loads(resp.content) - assert data == [] - - def test_invalid_if_modified_since_header(self): - """Should go back to 14 day default if param is not ISO date""" - header_date = "this is our concern Dude" - resp = export_json(self.rf.get("/", HTTP_IF_MODIFIED_SINCE=header_date)) - data = json.loads(resp.content) - release_versions = [o["version"] for o in data] - assert self.r1.version not in release_versions - assert self.r2.version not in release_versions - assert self.r3.version in release_versions - - def test_recently_modified_note(self): - """Should also return releases with notes modified more recently than 14 days ago""" - self.r1.note_set.create(note="The Dude minds, man") - resp = export_json(self.rf.get("/")) - data = json.loads(resp.content) - release_versions = [o["version"] for o in data] - assert self.r1.version in release_versions - assert self.r2.version not in release_versions - assert self.r3.version in release_versions - - def test_recently_modified_note_distinct(self): - """Should also return releases with notes modified more recently than 14 days ago""" - note = Note.objects.create(note="The Dude minds, man") - note2 = Note.objects.create(note="Careful, man, there’s a beverage here.") - self.r1.note_set.add(note) - self.r1.note_set.add(note2) - resp = export_json(self.rf.get("/")) - data = json.loads(resp.content) - release_versions = [o["version"] for o in data] - assert self.r1.version in release_versions - assert self.r2.version not in release_versions - assert self.r3.version in release_versions - assert len(release_versions) == 2 - - def test_recently_modified_fixed_in_note(self): - """Should also return releases with notes modified more recently than 14 days ago""" - self.r2.fixed_note_set.create(note="The Dude minds, man") - resp = export_json(self.rf.get("/")) - data = json.loads(resp.content) - release_versions = [o["version"] for o in data] - assert self.r1.version not in release_versions - assert self.r2.version in release_versions - assert self.r3.version in release_versions - - def test_all_as_list(self): - """Should return all releases with no dupes""" - note = Note.objects.create(note="The Dude minds, man") - note2 = Note.objects.create(note="Careful, man, there’s a beverage here.") - self.r1.note_set.add(note) - self.r1.note_set.add(note2) - resp = export_json(self.rf.get("/", {"all": "true"})) - data = json.loads(resp.content) - release_versions = [o["version"] for o in data] - assert self.r1.version in release_versions - assert self.r2.version in release_versions - assert self.r3.version in release_versions - assert len(release_versions) == 3 diff --git a/springfield/rna/views.py b/springfield/rna/views.py deleted file mode 100644 index cdbc22a1c..000000000 --- a/springfield/rna/views.py +++ /dev/null @@ -1,67 +0,0 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. - -import datetime -import json - -from django.conf import settings -from django.http import HttpResponse, HttpResponseForbidden -from django.shortcuts import get_object_or_404 -from django.utils.http import parse_http_date_safe -from django.utils.timezone import now -from django.views.decorators.cache import cache_page -from django.views.decorators.http import last_modified, require_safe - -from rest_framework import generics -from rest_framework.authtoken.models import Token -from rest_framework.viewsets import ModelViewSet - -from nucleus.rna.models import Note, Release -from nucleus.rna.serializers import NoteSerializer, ReleaseSerializer -from nucleus.rna.utils import HttpResponseJSON, get_last_modified_date - -RNA_JSON_CACHE_TIME = getattr(settings, "RNA_JSON_CACHE_TIME", 600) - - -def auth_token(request): - if request.user.is_active and request.user.is_staff: - token, created = Token.objects.get_or_create(user=request.user) - return HttpResponse(content=json.dumps({"token": token.key}), content_type="application/json") - else: - return HttpResponseForbidden() - - -class NoteViewSet(ModelViewSet): - queryset = Note.objects.all() - serializer_class = NoteSerializer - - -class ReleaseViewSet(ModelViewSet): - queryset = Release.objects.all() - serializer_class = ReleaseSerializer - - -class NestedNoteView(generics.ListAPIView): - model = Note - serializer_class = NoteSerializer - - def get_queryset(self): - release = get_object_or_404(Release, pk=self.kwargs.get("pk")) - return release.note_set.all() - - -@cache_page(RNA_JSON_CACHE_TIME) -@last_modified(get_last_modified_date) -@require_safe -def export_json(request): - if request.GET.get("all") == "true": - return HttpResponseJSON(Release.objects.all_as_list(), cors=True) - - mod_date = parse_http_date_safe(request.headers.get("If-Modified-Since")) - if mod_date: - mod_date = datetime.datetime.fromtimestamp(mod_date, datetime.UTC) - else: - mod_date = now() - datetime.timedelta(days=30) - - return HttpResponseJSON(Release.objects.recently_modified_list(mod_date=mod_date), cors=True)