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 @@
<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/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/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..4307cd326 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):
@@ -102,55 +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 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"""
@@ -161,7 +118,7 @@ def pre_save(self, model_instance, add):
return value
-class ProductReleaseQuerySet(models.QuerySet):
+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"
@@ -172,13 +129,12 @@ def product(self, product_name, channel_name=None, version=None):
if version:
q = q.filter(version=version)
- return q
+ return q.prefetch_related("note_set")
-
-class ProductReleaseManager(models.Manager):
+class ReleaseManager(models.Manager):
def get_queryset(self, include_drafts=False):
- qs = ProductReleaseQuerySet(self.model, using=self._db)
- if settings.DEV or include_drafts:
+ 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)
@@ -186,61 +142,39 @@ def get_queryset(self, include_drafts=False):
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)
+ 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()]
- objects = ProductReleaseManager()
+ def recently_modified_list(self, days_ago=7, mod_date=None):
+ if mod_date is None:
+ mod_date = now() - timedelta(days=days_ago)
- class Meta:
- ordering = ["-release_date"]
+ 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")]
- def __str__(self):
- return self.title
+
+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()
def get_absolute_url(self):
if self.product == "Firefox for Android":
@@ -253,6 +187,23 @@ def get_absolute_url(self):
prefix = "aurora" if self.channel == "Aurora" else "release"
return reverse(urlname, args=[self.version, prefix])
+ @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)
@@ -265,23 +216,19 @@ def major_version_int(self):
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
+
+ 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}&"
@@ -297,11 +244,15 @@ def equivalent_release_for_product(self, product):
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}.")
+ 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(releases, reverse=True, key=attrgetter("version_obj"))[0]
-
- return None
+ 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":
@@ -311,21 +262,175 @@ 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):
- if not self.notes:
- return self.notes
- return process_notes(self.notes)
+ 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
-@memoize(LONG_RN_CACHE_TIMEOUT)
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 +444,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 +456,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 %}
+
+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/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/