diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..cc359464 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release + +on: + push: + tags: + - '*' + +jobs: + build: + if: github.repository == 'jazzband/django-newsletter' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: | + setup.py + tox.ini + + - name: Install dependencies + run: | + python -m pip install -U pip + python -m pip install -U setuptools twine wheel + + - name: Build package + run: | + python setup.py --version + python setup.py sdist --format=gztar bdist_wheel + twine check dist/* + + - name: Upload packages to Jazzband + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@master + with: + user: jazzband + password: ${{ secrets.JAZZBAND_RELEASE_KEY }} + repository_url: https://jazzband.co/projects/django-newsletter/upload diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..9e910923 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,48 @@ +name: Test + +on: [push, pull_request] + +jobs: + build: + name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 5 + matrix: + python-version: ['3.7', '3.8', '3.9', '3.10'] + django-version: ['2.2', '3.1', '3.2', 'main'] + exclude: + # No such tox envs: + - {python-version: '3.7', django-version: 'main'} + - {python-version: '3.10', django-version: '2.2'} + - {python-version: '3.10', django-version: '3.1'} + - {python-version: '3.10', django-version: '3.2'} + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: | + setup.py + tox.ini + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade tox tox-gh-actions + + - name: Tox tests + run: | + tox -v + env: + DJANGO: ${{ matrix.django-version }} + + - name: Upload coverage + uses: codecov/codecov-action@v1 + with: + name: Python ${{ matrix.python-version }} diff --git a/.gitignore b/.gitignore index bea050bb..e33eb98a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ django_setuptest* docs/_* .idea/ venv/ +coverage.xml +.tox +.venv/ diff --git a/.landscape.yml b/.landscape.yml deleted file mode 100644 index ed35b527..00000000 --- a/.landscape.yml +++ /dev/null @@ -1,2 +0,0 @@ -ignore-paths: - - newsletter/south_migrations diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..a62c2094 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1 @@ +repos: [] \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 4dae500f..00000000 --- a/.travis.yml +++ /dev/null @@ -1,41 +0,0 @@ -os: linux - -language: python - -python: - - 3.5 - - 3.6 - - 3.7 - - 3.8 - -env: - - DJANGO="Django<2.3" # Django 2.2.x LTS - - DJANGO="Django<3.1" # Django 3.0.x - - DJANGO="Django<3.2" # Django 3.1.x - -cache: - directories: - - $HOME/.cache/pip - -matrix: - exclude: - # Django 3.0 and 3.1 don't support Python 3.5 - - env: DJANGO="Django<3.1" - python: 3.5 - - env: DJANGO="Django<3.2" - python: 3.5 - -# Command to install dependencies -install: - # Latest PIP uses wheel by default - - pip install --upgrade pip - - pip install $DJANGO - - pip install -r requirements.txt - - pip install -r requirements_test.txt - - pip install coveralls - -# Command to run tests -script: coverage run setup.py test - -after_success: - coveralls diff --git a/CHANGES.rst b/CHANGES.rst index 69177fb0..18003ac7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,6 +7,10 @@ Changes - Configurable thumbnailing, dropping hard sorl-thumbnail (#304). - File attachments for messages (#334). - Drop surlex dependency improved `path()` and `re_path()` (#339). +- Remove hard dependency on SITE_ID (#266) +- Drop support for Django 3.0 +- Add support for Django 3.2 +- Add support for Python 3.10 - ... 0.9.1 (18-05-2020) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..e0d5efab --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Code of Conduct + +As contributors and maintainers of the Jazzband projects, and in the interest of +fostering an open and welcoming community, we pledge to respect all people who +contribute through reporting issues, posting feature requests, updating documentation, +submitting pull requests or patches, and other activities. + +We are committed to making participation in the Jazzband a harassment-free experience +for everyone, regardless of the level of experience, gender, gender identity and +expression, sexual orientation, disability, personal appearance, body size, race, +ethnicity, age, religion, or nationality. + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery +- Personal attacks +- Trolling or insulting/derogatory comments +- Public or private harassment +- Publishing other's private information, such as physical or electronic addresses, + without explicit permission +- Other unethical or unprofessional conduct + +The Jazzband roadies have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are not +aligned to this Code of Conduct, or to ban temporarily or permanently any contributor +for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +By adopting this Code of Conduct, the roadies commit themselves to fairly and +consistently applying these principles to every aspect of managing the jazzband +projects. Roadies who do not follow or enforce the Code of Conduct may be permanently +removed from the Jazzband roadies. + +This code of conduct applies both within project spaces and in public spaces when an +individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by +contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and +investigated and will result in a response that is deemed necessary and appropriate to +the circumstances. Roadies are obligated to maintain confidentiality with regard to the +reporter of an incident. + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version +1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] + +[homepage]: https://contributor-covenant.org +[version]: https://contributor-covenant.org/version/1/3/0/ diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index af7b03e8..142f8db2 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -13,6 +13,6 @@ Thanks for your awesome contrib! If you'd like to have your code included in mas couple of things you have to take care of though: 1. Ensure that the way you implemented the functionality is generic enough for other users to make use of and does not degrade the performance of existing users. If you unsure about this, create an issue with proposed functionality to discuss with the collaborators first. -2. Make sure the tests are passing. In any case, Travis should report passing tests. -3. Extended tests to cover any additional code included in your commit. In any case, the coveralls report should report equal or increased coverage. +2. Make sure the tests are passing. In any case, GitHub Actions should report passing tests. +3. Extended tests to cover any additional code included in your commit. In any case, the Codecov report should report equal or increased coverage. 4. Make sure that any added or changed functionality is documented in the Sphinx documentation. diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 617a9b3e..00000000 --- a/Pipfile +++ /dev/null @@ -1,26 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -django-imperavi = "*" -django-tinymce = "*" -pytz = "*" -django-webtest = "*" -mock = "*" -WebTest = "*" -python-card-me = "<1.0" -ldif3 = "<3.2" -chardet = "*" -surlex = ">=0.2.0" -sorl-thumbnail = ">=12.6.3" -six = "*" -unicodecsv = "<0.15" -Django = ">=2.2.16" -Pillow = ">=6.2.2" - -[dev-packages] -coverage = "*" -transifex-client = "*" -twine = "*" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 5506ad91..00000000 --- a/Pipfile.lock +++ /dev/null @@ -1,451 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "c88397d77e5b23467f123b4884212deafc9bc3022cbf2f4b96d7c7fa33b546b4" - }, - "pipfile-spec": 6, - "requires": {}, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "asgiref": { - "hashes": [ - "sha256:7e51911ee147dd685c3c8b805c0ad0cb58d360987b56953878f8c06d2d1c6f1a", - "sha256:9fc6fb5d39b8af147ba40765234fa822b39818b12cc80b35ad9b0cef3a476aed" - ], - "version": "==3.2.10" - }, - "beautifulsoup4": { - "hashes": [ - "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35", - "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25", - "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666" - ], - "version": "==4.9.3" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "index": "pypi", - "version": "==3.0.4" - }, - "django": { - "hashes": [ - "sha256:a2127ad0150ec6966655bedf15dbbff9697cc86d61653db2da1afa506c0b04cc", - "sha256:c93c28ccf1d094cbd00d860e83128a39e45d2c571d3b54361713aaaf9a94cac4" - ], - "index": "pypi", - "version": "==3.1.2" - }, - "django-imperavi": { - "hashes": [ - "sha256:2b05ff0afbc8a23a28ab599abef6aa8e762f32b5830ce7522905b471763d3da2" - ], - "index": "pypi", - "version": "==0.2.3" - }, - "django-tinymce": { - "hashes": [ - "sha256:47db20515d159c69e3b8c69dca73adfb4e60010335250514d9f6fce6dc98c85c", - "sha256:d3a641ec0d10db05dacab7bf9b11e9c8114d017ad1a9132ba18b10ab6bff1934" - ], - "index": "pypi", - "version": "==3.1.0" - }, - "django-webtest": { - "hashes": [ - "sha256:b9b4b94670c0ce533efc456d02dd55a0d0a7a8f7912eb30728dca2d59d7948b4", - "sha256:c5a1e486a3d8d3623aa615b6db2f27de848aa7079303a84721e9a685f839796c" - ], - "index": "pypi", - "version": "==1.9.7" - }, - "ldif3": { - "hashes": [ - "sha256:ccdf6ac2ed3f88912b7509529694c0e289c4392d34f2095cca22fa6d70ac189a" - ], - "index": "pypi", - "version": "==3.1.1" - }, - "mock": { - "hashes": [ - "sha256:3f9b2c0196c60d21838f307f5825a7b86b678cedc58ab9e50a8988187b4d81e0", - "sha256:dd33eb70232b6118298d516bbcecd26704689c386594f0f3c4f13867b2c56f72" - ], - "index": "pypi", - "version": "==4.0.2" - }, - "pillow": { - "hashes": [ - "sha256:006de60d7580d81f4a1a7e9f0173dc90a932e3905cc4d47ea909bc946302311a", - "sha256:0a2e8d03787ec7ad71dc18aec9367c946ef8ef50e1e78c71f743bc3a770f9fae", - "sha256:0eeeae397e5a79dc088d8297a4c2c6f901f8fb30db47795113a4a605d0f1e5ce", - "sha256:11c5c6e9b02c9dac08af04f093eb5a2f84857df70a7d4a6a6ad461aca803fb9e", - "sha256:2fb113757a369a6cdb189f8df3226e995acfed0a8919a72416626af1a0a71140", - "sha256:4b0ef2470c4979e345e4e0cc1bbac65fda11d0d7b789dbac035e4c6ce3f98adb", - "sha256:59e903ca800c8cfd1ebe482349ec7c35687b95e98cefae213e271c8c7fffa021", - "sha256:5abd653a23c35d980b332bc0431d39663b1709d64142e3652890df4c9b6970f6", - "sha256:5f9403af9c790cc18411ea398a6950ee2def2a830ad0cfe6dc9122e6d528b302", - "sha256:6b4a8fd632b4ebee28282a9fef4c341835a1aa8671e2770b6f89adc8e8c2703c", - "sha256:6c1aca8231625115104a06e4389fcd9ec88f0c9befbabd80dc206c35561be271", - "sha256:795e91a60f291e75de2e20e6bdd67770f793c8605b553cb6e4387ce0cb302e09", - "sha256:7ba0ba61252ab23052e642abdb17fd08fdcfdbbf3b74c969a30c58ac1ade7cd3", - "sha256:7c9401e68730d6c4245b8e361d3d13e1035cbc94db86b49dc7da8bec235d0015", - "sha256:81f812d8f5e8a09b246515fac141e9d10113229bc33ea073fec11403b016bcf3", - "sha256:895d54c0ddc78a478c80f9c438579ac15f3e27bf442c2a9aa74d41d0e4d12544", - "sha256:8de332053707c80963b589b22f8e0229f1be1f3ca862a932c1bcd48dafb18dd8", - "sha256:92c882b70a40c79de9f5294dc99390671e07fc0b0113d472cbea3fde15db1792", - "sha256:95edb1ed513e68bddc2aee3de66ceaf743590bf16c023fb9977adc4be15bd3f0", - "sha256:b63d4ff734263ae4ce6593798bcfee6dbfb00523c82753a3a03cbc05555a9cc3", - "sha256:bd7bf289e05470b1bc74889d1466d9ad4a56d201f24397557b6f65c24a6844b8", - "sha256:cc3ea6b23954da84dbee8025c616040d9aa5eaf34ea6895a0a762ee9d3e12e11", - "sha256:cc9ec588c6ef3a1325fa032ec14d97b7309db493782ea8c304666fb10c3bd9a7", - "sha256:d3d07c86d4efa1facdf32aa878bd508c0dc4f87c48125cc16b937baa4e5b5e11", - "sha256:d8a96747df78cda35980905bf26e72960cba6d355ace4780d4bdde3b217cdf1e", - "sha256:e38d58d9138ef972fceb7aeec4be02e3f01d383723965bfcef14d174c8ccd039", - "sha256:eb472586374dc66b31e36e14720747595c2b265ae962987261f044e5cce644b5", - "sha256:fbd922f702582cb0d71ef94442bfca57624352622d75e3be7a1e7e9360b07e72" - ], - "index": "pypi", - "version": "==8.0.1" - }, - "python-card-me": { - "hashes": [ - "sha256:37ca483a574534177227152123b1e1381330c75a62c4d4d60cd2eeb188384bf2" - ], - "index": "pypi", - "version": "==0.9.3" - }, - "python-dateutil": { - "hashes": [ - "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", - "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" - ], - "version": "==2.8.1" - }, - "pytz": { - "hashes": [ - "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", - "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048" - ], - "index": "pypi", - "version": "==2020.1" - }, - "six": { - "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" - ], - "index": "pypi", - "version": "==1.15.0" - }, - "sorl-thumbnail": { - "hashes": [ - "sha256:66771521f3c0ed771e1ce8e1aaf1639ebff18f7f5a40cfd3083da8f0fe6c7c99", - "sha256:7162639057dff222a651bacbdb6bd6f558fc32946531d541fc71e10c0167ebdf" - ], - "index": "pypi", - "version": "==12.6.3" - }, - "soupsieve": { - "hashes": [ - "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", - "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" - ], - "markers": "python_version >= '3.0'", - "version": "==2.0.1" - }, - "sqlparse": { - "hashes": [ - "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", - "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" - ], - "version": "==0.4.1" - }, - "surlex": { - "hashes": [ - "sha256:62057b52d147bf83aec36205a42a0080e0a0de4d6c8b8d6ae56adf477253b2df" - ], - "index": "pypi", - "version": "==0.2.0" - }, - "unicodecsv": { - "hashes": [ - "sha256:018c08037d48649a0412063ff4eda26eaa81eff1546dbffa51fa5293276ff7fc" - ], - "index": "pypi", - "version": "==0.14.1" - }, - "waitress": { - "hashes": [ - "sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261", - "sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db" - ], - "version": "==1.4.4" - }, - "webob": { - "hashes": [ - "sha256:a3c89a8e9ba0aeb17382836cdb73c516d0ecf6630ec40ec28288f3ed459ce87b", - "sha256:aa3a917ed752ba3e0b242234b2a373f9c4e2a75d35291dcbe977649bd21fd108" - ], - "version": "==1.8.6" - }, - "webtest": { - "hashes": [ - "sha256:44ddfe99b5eca4cf07675e7222c81dd624d22f9a26035d2b93dc8862dc1153c6", - "sha256:aac168b5b2b4f200af4e35867cf316712210e3d5db81c1cbdff38722647bb087" - ], - "index": "pypi", - "version": "==2.0.35" - } - }, - "develop": { - "bleach": { - "hashes": [ - "sha256:52b5919b81842b1854196eaae5ca29679a2f2e378905c346d3ca8227c2c66080", - "sha256:9f8ccbeb6183c6e6cddea37592dfb0167485c1e3b13b3363bc325aa8bda3adbd" - ], - "version": "==3.2.1" - }, - "certifi": { - "hashes": [ - "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", - "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" - ], - "version": "==2020.6.20" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "index": "pypi", - "version": "==3.0.4" - }, - "colorama": { - "hashes": [ - "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", - "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" - ], - "version": "==0.4.4" - }, - "coverage": { - "hashes": [ - "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516", - "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259", - "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9", - "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097", - "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0", - "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f", - "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7", - "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c", - "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5", - "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7", - "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729", - "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978", - "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9", - "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f", - "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9", - "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822", - "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418", - "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82", - "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f", - "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d", - "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221", - "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4", - "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21", - "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709", - "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54", - "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d", - "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270", - "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24", - "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751", - "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a", - "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237", - "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7", - "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636", - "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8" - ], - "index": "pypi", - "version": "==5.3" - }, - "docutils": { - "hashes": [ - "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", - "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" - ], - "version": "==0.16" - }, - "gitdb": { - "hashes": [ - "sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac", - "sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9" - ], - "version": "==4.0.5" - }, - "gitpython": { - "hashes": [ - "sha256:6eea89b655917b500437e9668e4a12eabdcf00229a0df1762aabd692ef9b746b", - "sha256:befa4d101f91bad1b632df4308ec64555db684c360bd7d2130b4807d49ce86b8" - ], - "version": "==3.1.11" - }, - "idna": { - "hashes": [ - "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", - "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" - ], - "version": "==2.10" - }, - "importlib-metadata": { - "hashes": [ - "sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da", - "sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3" - ], - "markers": "python_version < '3.8'", - "version": "==2.0.0" - }, - "keyring": { - "hashes": [ - "sha256:4e34ea2fdec90c1c43d6610b5a5fafa1b9097db1802948e90caf5763974b8f8d", - "sha256:9aeadd006a852b78f4b4ef7c7556c2774d2432bbef8ee538a3e9089ac8b11466" - ], - "version": "==21.4.0" - }, - "packaging": { - "hashes": [ - "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", - "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" - ], - "version": "==20.4" - }, - "pkginfo": { - "hashes": [ - "sha256:78d032b5888ec06d7f9d18fbf8c0549a6a3477081b34cb769119a07183624fc1", - "sha256:dd008e95b13141ddd05d7e8881f0c0366a998ab90b25c2db794a1714b71583cc" - ], - "version": "==1.6.0" - }, - "pygments": { - "hashes": [ - "sha256:381985fcc551eb9d37c52088a32914e00517e57f4a21609f48141ba08e193fa0", - "sha256:88a0bbcd659fcb9573703957c6b9cff9fab7295e6e76db54c9d00ae42df32773" - ], - "version": "==2.7.2" - }, - "pyparsing": { - "hashes": [ - "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", - "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" - ], - "version": "==2.4.7" - }, - "python-slugify": { - "hashes": [ - "sha256:69a517766e00c1268e5bbfc0d010a0a8508de0b18d30ad5a1ff357f8ae724270" - ], - "version": "==4.0.1" - }, - "readme-renderer": { - "hashes": [ - "sha256:267854ac3b1530633c2394ead828afcd060fc273217c42ac36b6be9c42cd9a9d", - "sha256:6b7e5aa59210a40de72eb79931491eaf46fefca2952b9181268bd7c7c65c260a" - ], - "version": "==28.0" - }, - "requests": { - "hashes": [ - "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", - "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" - ], - "version": "==2.24.0" - }, - "requests-toolbelt": { - "hashes": [ - "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f", - "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0" - ], - "version": "==0.9.1" - }, - "rfc3986": { - "hashes": [ - "sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d", - "sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50" - ], - "version": "==1.4.0" - }, - "six": { - "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" - ], - "index": "pypi", - "version": "==1.15.0" - }, - "smmap": { - "hashes": [ - "sha256:54c44c197c819d5ef1991799a7e30b662d1e520f2ac75c9efbeb54a742214cf4", - "sha256:9c98bbd1f9786d22f14b3d4126894d56befb835ec90cef151af566c7e19b5d24" - ], - "version": "==3.0.4" - }, - "text-unidecode": { - "hashes": [ - "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", - "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93" - ], - "version": "==1.3" - }, - "tqdm": { - "hashes": [ - "sha256:43ca183da3367578ebf2f1c2e3111d51ea161ed1dc4e6345b86e27c2a93beff7", - "sha256:69dfa6714dee976e2425a9aab84b622675b7b1742873041e3db8a8e86132a4af" - ], - "version": "==4.50.2" - }, - "transifex-client": { - "hashes": [ - "sha256:a8a06330acb97403b24153fb51c2c6ae5c8ab0a989fed06f8a27ce70323c7d5e" - ], - "index": "pypi", - "version": "==0.14.1" - }, - "twine": { - "hashes": [ - "sha256:34352fd52ec3b9d29837e6072d5a2a7c6fe4290e97bba46bb8d478b5c598f7ab", - "sha256:ba9ff477b8d6de0c89dd450e70b2185da190514e91c42cc62f96850025c10472" - ], - "index": "pypi", - "version": "==3.2.0" - }, - "urllib3": { - "hashes": [ - "sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2", - "sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e" - ], - "version": "==1.25.11" - }, - "webencodings": { - "hashes": [ - "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", - "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" - ], - "version": "==0.5.1" - }, - "zipp": { - "hashes": [ - "sha256:50a4ef266c31c9409627b46012e206382eb8d23f46fbd358065a8335cfbf7d8f", - "sha256:adf8f2ed8f614ced567d849cae9d183cef6cfd27c77a5cae7a28029be0c2b7a7" - ], - "version": "==3.3.2" - } - } -} diff --git a/README.rst b/README.rst index 83581915..784f8a33 100644 --- a/README.rst +++ b/README.rst @@ -5,15 +5,20 @@ django-newsletter .. image:: https://img.shields.io/pypi/v/django-newsletter.svg :target: https://pypi.python.org/pypi/django-newsletter -.. image:: https://img.shields.io/travis/dokterbob/django-newsletter/master.svg - :target: http://travis-ci.org/dokterbob/django-newsletter +.. image:: https://img.shields.io/pypi/pyversions/django-newsletter.svg + :target: https://pypi.org/project/django-newsletter/ + :alt: Supported Python versions -.. image:: https://coveralls.io/repos/dokterbob/django-newsletter/badge.svg?branch=master&service=github - :target: https://coveralls.io/github/dokterbob/django-newsletter?branch=master +.. image:: https://img.shields.io/pypi/djversions/django-newsletter.svg + :target: https://pypi.org/project/django-newsletter/ + :alt: Supported Django versions -.. image:: https://landscape.io/github/dokterbob/django-newsletter/master/landscape.svg?style=flat - :target: https://landscape.io/github/dokterbob/django-newsletter/master - :alt: Code Health +.. image:: https://github.com/jazzband/django-newsletter/workflows/Test/badge.svg + :target: https://github.com/jazzband/django-newsletter/actions + :alt: GitHub Actions + +.. image:: https://codecov.io/gh/jazzband/django-newsletter/branch/master/graph/badge.svg + :target: https://codecov.io/gh/jazzband/django-newsletter .. image:: https://jazzband.co/static/img/badge.svg :target: https://jazzband.co/ @@ -44,12 +49,12 @@ Strings have been fully translated to a lot of languages with many more on their .. image:: https://www.transifex.com/projects/p/django-newsletter/resource/django/chart/image_png :target: http://www.transifex.com/projects/p/django-newsletter/ -Contributions to translations are welcome through `Transifex `_. Strings will be included as +Contributions to translations are welcome through `Transifex `_. Strings will be included as soon as near-full coverage is reached. Compatibility ============= -Currently, django-newsletter officially supports Django 2.2.x LTS, 3.0.x and 3.1.x and Python 3.5 through 3.8. +Currently, django-newsletter officially supports Django 2.2.x LTS, 3.1.x and 3.2.x and Python 3.7 through 3.10. Requirements ============ @@ -62,7 +67,7 @@ Fairly extensive tests are available for internal frameworks, web (un)subscription and mail sending. Sending a newsletter to large groups of recipients (+15k) has been confirmed to work in multiple production environments. Tests for pull req's and the master branch are automatically run through -`Travis CI `_. +`GitHub Actions `_. Contributing ============= diff --git a/docs/conf.py b/docs/conf.py index 45ffb757..b9c3cf11 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # django-newsletter documentation build configuration file, created by # sphinx-quickstart on Wed Nov 13 13:53:07 2013. @@ -11,7 +10,10 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os +import os +import sys +from pkg_resources import get_distribution + # Determine whether rendering on RTD on_rtd = os.environ.get('READTHEDOCS', None) == 'True' @@ -73,17 +75,16 @@ master_doc = 'index' # General information about the project. -project = u'django-newsletter' -copyright = u'2013, Mathijs de Bruin' +project = 'django-newsletter' +copyright = '2013, Mathijs de Bruin' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # -# The short X.Y version. -version = '0.5' -# The full version, including alpha/beta/rc tags. -release = '0.5.1' +release = get_distribution('django-newsletter').version +# for example take major/minor +version = '.'.join(release.split('.')[:2]) # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -222,8 +223,8 @@ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'django-newsletter.tex', u'django-newsletter Documentation', - u'Mathijs de Bruin', 'manual'), + ('index', 'django-newsletter.tex', 'django-newsletter Documentation', + 'Mathijs de Bruin', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -252,8 +253,8 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'django-newsletter', u'django-newsletter Documentation', - [u'Mathijs de Bruin'], 1) + ('index', 'django-newsletter', 'django-newsletter Documentation', + ['Mathijs de Bruin'], 1) ] # If true, show URL addresses after external links. @@ -266,8 +267,8 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'django-newsletter', u'django-newsletter Documentation', - u'Mathijs de Bruin', 'django-newsletter', 'One line description of project.', + ('index', 'django-newsletter', 'django-newsletter Documentation', + 'Mathijs de Bruin', 'django-newsletter', 'One line description of project.', 'Miscellaneous'), ] diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..8ee7ffd4 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1 @@ +sorl-thumbnail diff --git a/newsletter/__init__.py b/newsletter/__init__.py index e69de29b..6b350941 100644 --- a/newsletter/__init__.py +++ b/newsletter/__init__.py @@ -0,0 +1,7 @@ +from pkg_resources import get_distribution, DistributionNotFound + +try: + __version__ = get_distribution("django-newsletter").version +except DistributionNotFound: + # package is not installed + __version__ = None diff --git a/newsletter/addressimport/parsers.py b/newsletter/addressimport/parsers.py index 483eda45..651501ee 100644 --- a/newsletter/addressimport/parsers.py +++ b/newsletter/addressimport/parsers.py @@ -11,7 +11,7 @@ from newsletter.models import Subscription -class AddressList(object): +class AddressList: """ List with unique addresses. """ def __init__(self, newsletter, ignore_errors=False): diff --git a/newsletter/admin.py b/newsletter/admin.py index 7f7529a0..8e4e344e 100644 --- a/newsletter/admin.py +++ b/newsletter/admin.py @@ -1,13 +1,9 @@ -from __future__ import unicode_literals - import logging from django.urls import path logger = logging.getLogger(__name__) -import six - from django.db import models from django.conf import settings @@ -50,13 +46,13 @@ ) from django.utils.timezone import now +from django.urls import reverse from .admin_forms import ( SubmissionAdminForm, SubscriptionAdminForm, ImportForm, ConfirmForm, ArticleFormSet ) from .admin_utils import ExtendibleModelAdminMixin, make_subscription -from .compat import get_context, reverse from .fields import DynamicImageField from .settings import newsletter_settings @@ -98,11 +94,11 @@ def admin_submissions(self, obj): admin_submissions.short_description = '' -class NewsletterAdminLinkMixin(object): +class NewsletterAdminLinkMixin: def admin_newsletter(self, obj): opts = Newsletter._meta newsletter = obj.newsletter - url = reverse('admin:%s_%s_change' % (opts.app_label, opts.model_name), + url = reverse(f'admin:{opts.app_label}_{opts.model_name}_change', args=(newsletter.id,), current_app=self.admin_site.name) return format_html('{}', url, newsletter) @@ -192,7 +188,7 @@ def submit(self, request, object_id): """ URLs """ def get_urls(self): - urls = super(SubmissionAdmin, self).get_urls() + urls = super().get_urls() my_urls = [ path( @@ -240,7 +236,7 @@ def has_change_permission(self, request, obj=None): else: ArticleInlineClassTuple = (StackedInline,) -BaseArticleInline = type(str('BaseArticleInline'), ArticleInlineClassTuple, {}) +BaseArticleInline = type('BaseArticleInline', ArticleInlineClassTuple, {}) class ArticleInline(BaseArticleInline): model = Article @@ -314,12 +310,14 @@ def preview_html(self, request, object_id): 'message belongs to.' )) - c = get_context({'message': message, - 'site': Site.objects.get_current(), - 'newsletter': message.newsletter, - 'date': now(), - 'STATIC_URL': settings.STATIC_URL, - 'MEDIA_URL': settings.MEDIA_URL}) + c = { + 'message': message, + 'site': Site.objects.get_current(request), + 'newsletter': message.newsletter, + 'date': now(), + 'STATIC_URL': settings.STATIC_URL, + 'MEDIA_URL': settings.MEDIA_URL + } return HttpResponse(message.html_template.render(c)) @@ -327,14 +325,14 @@ def preview_html(self, request, object_id): def preview_text(self, request, object_id): message = self._getobj(request, object_id) - c = get_context({ + c = { 'message': message, - 'site': Site.objects.get_current(), + 'site': Site.objects.get_current(request), 'newsletter': message.newsletter, 'date': now(), 'STATIC_URL': settings.STATIC_URL, 'MEDIA_URL': settings.MEDIA_URL - }, autoescape=False) + } return HttpResponse( message.text_template.render(c), @@ -342,7 +340,7 @@ def preview_text(self, request, object_id): ) def submit(self, request, object_id): - submission = Submission.from_message(self._getobj(request, object_id)) + submission = Submission.from_message(self._getobj(request, object_id), request) change_url = reverse( 'admin:newsletter_submission_change', args=[submission.id]) @@ -359,7 +357,7 @@ def subscribers_json(self, request, object_id): """ URLs """ def get_urls(self): - urls = super(MessageAdmin, self).get_urls() + urls = super().get_urls() my_urls = [ path('/preview/', @@ -507,7 +505,7 @@ def subscribers_import_confirm(self, request): form = ConfirmForm(request.POST) if form.is_valid(): try: - for email, name in six.iteritems(addresses): + for email, name in addresses.items(): address_inst = make_subscription( newsletter, email, name ) @@ -540,7 +538,7 @@ def subscribers_import_confirm(self, request): """ URLs """ def get_urls(self): - urls = super(SubscriptionAdmin, self).get_urls() + urls = super().get_urls() my_urls = [ path('import/', diff --git a/newsletter/admin_forms.py b/newsletter/admin_forms.py index 5919d7f2..f3180290 100644 --- a/newsletter/admin_forms.py +++ b/newsletter/admin_forms.py @@ -110,7 +110,7 @@ class Meta: } def __init__(self, *args, **kwargs): - super(SubscriptionAdminForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.fields['subscribed'].label = _('Status') @@ -131,7 +131,7 @@ def clean_name_field(self): return data def clean(self): - cleaned_data = super(SubscriptionAdminForm, self).clean() + cleaned_data = super().clean() if not (cleaned_data.get('user', None) or cleaned_data.get('email_field', None)): @@ -172,7 +172,7 @@ class ArticleFormSet(forms.BaseInlineFormSet): """ Formset for articles yielding default sortoder. """ def __init__(self, *args, **kwargs): - super(ArticleFormSet, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) assert self.instance next_sortorder = self.instance.get_next_article_sortorder() diff --git a/newsletter/admin_utils.py b/newsletter/admin_utils.py index e89b854f..51299075 100644 --- a/newsletter/admin_utils.py +++ b/newsletter/admin_utils.py @@ -2,12 +2,12 @@ from django.contrib.admin.utils import unquote from django.http import Http404 -from django.utils.encoding import force_text +from django.utils.encoding import force_str from django.utils.translation import gettext as _ from .models import Subscription -class ExtendibleModelAdminMixin(object): +class ExtendibleModelAdminMixin: def _getobj(self, request, object_id): opts = self.model._meta @@ -25,8 +25,8 @@ def _getobj(self, request, object_id): '%(name)s object with primary key ' '\'%(key)s\' does not exist.' ) % { - 'name': force_text(opts.verbose_name), - 'key': force_text(object_id) + 'name': force_str(opts.verbose_name), + 'key': force_str(object_id) } ) diff --git a/newsletter/compat.py b/newsletter/compat.py deleted file mode 100644 index df482f96..00000000 --- a/newsletter/compat.py +++ /dev/null @@ -1,16 +0,0 @@ -from django import get_version - -try: - from django.urls import reverse -except ImportError: # Django < 1.10 - from django.core.urlresolvers import reverse - -if get_version() < '1.10': - from django.template import Context - -def get_context(dictionary, **kwargs): - """Takes a dict and returns the correct object for template rendering.""" - if get_version() < '1.10': - return Context(dictionary, **kwargs) - else: - return dictionary diff --git a/newsletter/forms.py b/newsletter/forms.py index 80d10060..ed0507d0 100644 --- a/newsletter/forms.py +++ b/newsletter/forms.py @@ -25,7 +25,7 @@ def __init__(self, *args, **kwargs): else: ip = None - super(NewsletterForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.instance.newsletter = newsletter @@ -88,7 +88,7 @@ def clean(self): _("This subscription has not yet been activated.") ) - return super(UpdateRequestForm, self).clean() + return super().clean() def clean_email_field(self): data = self.cleaned_data['email_field'] @@ -121,7 +121,7 @@ def clean(self): _("This subscription has already been unsubscribed from.") ) - return super(UnsubscribeRequestForm, self).clean() + return super().clean() class UpdateForm(NewsletterForm): diff --git a/newsletter/management/__init__.py b/newsletter/management/__init__.py index 6bacb7f8..01c7981b 100644 --- a/newsletter/management/__init__.py +++ b/newsletter/management/__init__.py @@ -1,2 +1 @@ -# -*- coding: utf-8 -*- # management commands for the newsletter diff --git a/newsletter/management/commands/__init__.py b/newsletter/management/commands/__init__.py index 6bacb7f8..01c7981b 100644 --- a/newsletter/management/commands/__init__.py +++ b/newsletter/management/commands/__init__.py @@ -1,2 +1 @@ -# -*- coding: utf-8 -*- # management commands for the newsletter diff --git a/newsletter/management/commands/submit_newsletter.py b/newsletter/management/commands/submit_newsletter.py index 5258ade8..a2ad5a37 100644 --- a/newsletter/management/commands/submit_newsletter.py +++ b/newsletter/management/commands/submit_newsletter.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ actual sending of the submissions """ diff --git a/newsletter/migrations/0001_initial.py b/newsletter/migrations/0001_initial.py index 2f27395b..9b93c460 100644 --- a/newsletter/migrations/0001_initial.py +++ b/newsletter/migrations/0001_initial.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import models, migrations import sorl.thumbnail.fields import newsletter.utils @@ -108,7 +105,7 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='subscription', - unique_together=set([('user', 'email_field', 'newsletter')]), + unique_together={('user', 'email_field', 'newsletter')}, ), migrations.AddField( model_name='submission', @@ -124,7 +121,7 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='message', - unique_together=set([('slug', 'newsletter')]), + unique_together={('slug', 'newsletter')}, ), migrations.AddField( model_name='article', diff --git a/newsletter/migrations/0002_auto_20150416_1555.py b/newsletter/migrations/0002_auto_20150416_1555.py index 45a28b67..6349d3e2 100644 --- a/newsletter/migrations/0002_auto_20150416_1555.py +++ b/newsletter/migrations/0002_auto_20150416_1555.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import models, migrations import django.db.models.manager import django.contrib.sites.managers diff --git a/newsletter/migrations/0003_auto_20160226_1518.py b/newsletter/migrations/0003_auto_20160226_1518.py index 5d0bfad5..2ad7cfbf 100644 --- a/newsletter/migrations/0003_auto_20160226_1518.py +++ b/newsletter/migrations/0003_auto_20160226_1518.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals import logging logger = logging.getLogger(__name__) @@ -34,6 +32,6 @@ class Migration(migrations.Migration): migrations.RunPython(renumerate_article_sortorder), migrations.AlterUniqueTogether( name='article', - unique_together=set([('post', 'sortorder')]), + unique_together={('post', 'sortorder')}, ), ] diff --git a/newsletter/migrations/0004_auto_20180407_1043.py b/newsletter/migrations/0004_auto_20180407_1043.py index d0319648..73361ae4 100644 --- a/newsletter/migrations/0004_auto_20180407_1043.py +++ b/newsletter/migrations/0004_auto_20180407_1043.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import migrations, models import newsletter.models diff --git a/newsletter/migrations/0009_submission_site.py b/newsletter/migrations/0009_submission_site.py new file mode 100644 index 00000000..d4f9d275 --- /dev/null +++ b/newsletter/migrations/0009_submission_site.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.5 on 2021-01-11 04:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0002_alter_domain_unique'), + ('newsletter', '0008_longer_subscription_name'), + ] + + operations = [ + migrations.AlterModelManagers( + name='newsletter', + managers=[ + ], + ), + migrations.AddField( + model_name='submission', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='sites.site', verbose_name='Site for this submission'), + ), + ] diff --git a/newsletter/models.py b/newsletter/models.py index 58ab530a..7776a4ae 100644 --- a/newsletter/models.py +++ b/newsletter/models.py @@ -7,7 +7,8 @@ from django.conf import settings from django.contrib.sites.models import Site -from django.contrib.sites.managers import CurrentSiteManager +from django.contrib.sites.shortcuts import get_current_site +from django.core.exceptions import ValidationError from django.core.mail import EmailMultiAlternatives from django.db import models from django.template.loader import select_template @@ -15,10 +16,10 @@ from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext from django.utils.timezone import now +from django.urls import reverse from distutils.version import LooseVersion -from .compat import get_context, reverse from .fields import DynamicImageField from .utils import ( make_activation_code, get_default_sites, ACTIONS @@ -55,9 +56,6 @@ class Newsletter(models.Model): objects = models.Manager() - # Automatically filter the current site - on_site = CurrentSiteManager() - def get_templates(self, action): """ Return a subject, text, HTML tuple with e-mail templates for @@ -272,7 +270,7 @@ def save(self, *args, **kwargs): elif self.unsubscribed: self._unsubscribe() - super(Subscription, self).save(*args, **kwargs) + super().save(*args, **kwargs) ip = models.GenericIPAddressField(_("IP address"), blank=True, null=True) @@ -324,25 +322,27 @@ class Meta: def get_recipient(self): return get_address(self.name, self.email) - def send_activation_email(self, action): + def send_activation_email(self, request_or_site, action): assert action in ACTIONS, 'Unknown action: %s' % action (subject_template, text_template, html_template) = \ self.newsletter.get_templates(action) + if isinstance(request_or_site, Site): + site = request_or_site + else: + site = get_current_site(request_or_site) variable_dict = { 'subscription': self, - 'site': Site.objects.get_current(), + 'site': site, 'newsletter': self.newsletter, 'date': self.subscribe_date, 'STATIC_URL': settings.STATIC_URL, 'MEDIA_URL': settings.MEDIA_URL } - unescaped_context = get_context(variable_dict, autoescape=False) - - subject = subject_template.render(unescaped_context).strip() - text = text_template.render(unescaped_context) + subject = subject_template.render(variable_dict).strip() + text = text_template.render(variable_dict) message = EmailMultiAlternatives( subject, text, @@ -351,10 +351,8 @@ def send_activation_email(self, action): ) if html_template: - escaped_context = get_context(variable_dict) - message.attach_alternative( - html_template.render(escaped_context), "text/html" + html_template.render(variable_dict), "text/html" ) message.send() @@ -439,7 +437,7 @@ def save(self, **kwargs): # as to assure uniqueness. self.sortorder = self.post.get_next_article_sortorder() - super(Article, self).save() + super().save() def attachment_upload_to(instance, filename): @@ -458,7 +456,7 @@ class Meta: verbose_name_plural = _('attachments') def __str__(self): - return _(u"%(file_name)s on %(message)s") % { + return _("%(file_name)s on %(message)s") % { 'file_name': self.file_name, 'message': self.message } @@ -567,11 +565,17 @@ def __str__(self): 'publish_date': self.publish_date } + def get_site(self) -> Site: + if self.site is not None: + return self.site + else: + return Site.objects.get_current() + @cached_property def extra_headers(self): return { - 'List-Unsubscribe': 'http://%s%s' % ( - Site.objects.get_current().domain, + 'List-Unsubscribe': 'http://{}{}'.format( + self.get_site().domain, reverse('newsletter_unsubscribe_request', args=[self.message.newsletter.slug]) ), @@ -608,7 +612,7 @@ def submit(self): def send_message(self, subscription): variable_dict = { 'subscription': subscription, - 'site': Site.objects.get_current(), + 'site': self.get_site(), 'submission': self, 'message': self.message, 'newsletter': self.newsletter, @@ -617,11 +621,9 @@ def send_message(self, subscription): 'MEDIA_URL': settings.MEDIA_URL } - unescaped_context = get_context(variable_dict, autoescape=False) - subject = self.message.subject_template.render( - unescaped_context).strip() - text = self.message.text_template.render(unescaped_context) + variable_dict).strip() + text = self.message.text_template.render(variable_dict) message = EmailMultiAlternatives( subject, text, @@ -636,10 +638,8 @@ def send_message(self, subscription): message.attach_file(attachment.file.path) if self.message.html_template: - escaped_context = get_context(variable_dict) - message.attach_alternative( - self.message.html_template.render(escaped_context), + self.message.html_template.render(variable_dict), "text/html" ) @@ -671,11 +671,18 @@ def submit_queue(cls): submission.submit() @classmethod - def from_message(cls, message): + def from_message(cls, message, request_or_site): logger.debug(gettext('Submission of message %s'), message) submission = cls() submission.message = message submission.newsletter = message.newsletter + if request_or_site is not None: + if isinstance(request_or_site, Site): + site = request_or_site + else: + site = get_current_site(request_or_site) + submission.site = site + submission.full_clean() submission.save() try: submission.subscriptions.set(message.newsletter.get_subscriptions()) @@ -683,13 +690,32 @@ def from_message(cls, message): submission.subscriptions = message.newsletter.get_subscriptions() return submission + def clean(self): + super().clean() + + newsletter = self.message.newsletter + if newsletter is None: + newsletter = self.newsletter + + sites = set([site.id for site in newsletter.site.all()]) + + if len(sites) > 0: + if self.site is not None and self.site.id not in sites: + raise ValidationError( + {'site': _("Site must be one of sites associated with the newsletter")} + ) + elif self.site is None: + raise ValidationError( + {'site': _("Site cannot be empty when the newsletter has associated sites")} + ) + def save(self, **kwargs): """ Set the newsletter from associated message upon saving. """ assert self.message.newsletter self.newsletter = self.message.newsletter - return super(Submission, self).save() + return super().save() def get_absolute_url(self): assert self.newsletter.slug @@ -705,6 +731,17 @@ def get_absolute_url(self): } ) + # Since multiple sites might be creating multiple submissions, we must track which one this belongs to + # And it must be in the subset for the eligible ones of the newsletter + # If not set, then the current site will be used + site = models.ForeignKey( + Site, + verbose_name=_("Site for this submission"), + on_delete=models.CASCADE, + null=True, + blank=True + ) + newsletter = models.ForeignKey( Newsletter, verbose_name=_('newsletter'), editable=False, on_delete=models.CASCADE @@ -751,6 +788,6 @@ def get_address(name, email): if LooseVersion(django.get_version()) < LooseVersion('1.9'): name = name.encode('ascii', 'ignore').decode('ascii').strip() if name: - return '%s <%s>' % (name, email) + return f'{name} <{email}>' else: return '%s' % email diff --git a/newsletter/settings.py b/newsletter/settings.py index d54c4fe0..fa3ba059 100644 --- a/newsletter/settings.py +++ b/newsletter/settings.py @@ -7,7 +7,7 @@ from .utils import Singleton -class Settings(object): +class Settings: """ A settings object that proxies settings and handles defaults, inspired by `django-appconf` and the way it works in `django-rest-framework`. @@ -44,7 +44,7 @@ def __getattr__(self, attr): try: setting = getattr( django_settings, - '%s_%s' % (self.settings_prefix, attr), + f'{self.settings_prefix}_{attr}', ) except AttributeError: if not attr.startswith('DEFAULT_'): diff --git a/newsletter/utils.py b/newsletter/utils.py index ed43d7c5..4293c49a 100644 --- a/newsletter/utils.py +++ b/newsletter/utils.py @@ -35,7 +35,7 @@ class Singleton(type): def __call__(cls, *args, **kwargs): if cls not in cls._instances: - cls._instances[cls] = super(Singleton, cls).__call__( + cls._instances[cls] = super().__call__( *args, **kwargs ) diff --git a/newsletter/views.py b/newsletter/views.py index a9d7728a..3805bdd7 100644 --- a/newsletter/views.py +++ b/newsletter/views.py @@ -5,13 +5,14 @@ from smtplib import SMTPException +from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import ValidationError, ImproperlyConfigured from django.conf import settings from django.template.response import SimpleTemplateResponse from django.shortcuts import get_object_or_404, redirect -from django.http import Http404 +from django.http import Http404, HttpRequest from django.views.generic import ( ListView, DetailView, @@ -26,10 +27,10 @@ from django.utils.decorators import method_decorator from django.utils.translation import gettext, gettext_lazy as _ from django.utils import timezone +from django.urls import reverse from django.forms.models import modelformset_factory -from .compat import reverse from .models import Newsletter, Subscription, Submission from .forms import ( SubscribeRequestForm, UserUpdateForm, UpdateRequestForm, @@ -47,12 +48,25 @@ def is_authenticated(user): return user.is_authenticated if isinstance(user.is_authenticated, bool) else user.is_authenticated() -class NewsletterViewBase(object): +class SiteViewBase: + def get_site(self, request: HttpRequest = None): + if request is None: + request = self.request + site = getattr(request, "site", None) + if site is not None: + return site + return get_current_site(request) + + +class NewsletterViewBase(SiteViewBase): """ Base class for newsletter views. """ - queryset = Newsletter.on_site.filter(visible=True) allow_empty = False slug_url_kwarg = 'newsletter_slug' + def get_queryset(self): + site = self.get_site() + return Newsletter.objects.filter(visible=True, site__id=site.id) + class NewsletterDetailView(NewsletterViewBase, DetailView): pass @@ -68,10 +82,10 @@ def post(self, request, **kwargs): """ Allow post requests. """ # All logic (for now) occurs in the form logic - return super(NewsletterListView, self).get(request, **kwargs) + return super().get(request, **kwargs) def get_context_data(self, **kwargs): - context = super(NewsletterListView, self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) if is_authenticated(self.request.user): # Add a formset for logged in users. @@ -139,29 +153,29 @@ def get_formset(self): return formset -class ProcessUrlDataMixin(object): +class ProcessUrlDataMixin: """ Mixin providing the ability to process args and kwargs from url before dispatching request. """ - def process_url_data(self, *args, **kwargs): + def process_url_data(self, request, *args, **kwargs): """ Subclasses should put url data processing in this method. """ pass - def dispatch(self, *args, **kwargs): - self.process_url_data(*args, **kwargs) + def dispatch(self, request, *args, **kwargs): + self.process_url_data(request, *args, **kwargs) - return super(ProcessUrlDataMixin, self).dispatch(*args, **kwargs) + return super(ProcessUrlDataMixin, self).dispatch(request, *args, **kwargs) -class NewsletterMixin(ProcessUrlDataMixin): +class NewsletterMixin(SiteViewBase, ProcessUrlDataMixin): """ Mixin retrieving newsletter based on newsletter_slug from url and adding it to context and form kwargs. """ - def process_url_data(self, *args, **kwargs): + def process_url_data(self, request, *args, **kwargs): """ Get newsletter based on `newsletter_slug` from url and add it to instance attributes. @@ -169,11 +183,11 @@ def process_url_data(self, *args, **kwargs): assert 'newsletter_slug' in kwargs - super(NewsletterMixin, self).process_url_data(*args, **kwargs) + super(NewsletterMixin, self).process_url_data(request, *args, **kwargs) newsletter_queryset = kwargs.get( 'newsletter_queryset', - Newsletter.on_site.all() + Newsletter.objects.filter(site__id=self.get_site(request).id) ) newsletter_slug = kwargs['newsletter_slug'] @@ -183,7 +197,7 @@ def process_url_data(self, *args, **kwargs): def get_form_kwargs(self): """ Add newsletter to form kwargs. """ - kwargs = super(NewsletterMixin, self).get_form_kwargs() + kwargs = super().get_form_kwargs() kwargs['newsletter'] = self.newsletter @@ -191,7 +205,7 @@ def get_form_kwargs(self): def get_context_data(self, **kwargs): """ Add newsletter to context. """ - context = super(NewsletterMixin, self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) context['newsletter'] = self.newsletter @@ -203,9 +217,9 @@ class ActionMixin(ProcessUrlDataMixin): action = None - def process_url_data(self, *args, **kwargs): + def process_url_data(self, request, *args, **kwargs): """ Add action from url to instance attributes if not already set. """ - super(ActionMixin, self).process_url_data(*args, **kwargs) + super(ActionMixin, self).process_url_data(request, *args, **kwargs) if self.action is None: assert 'action' in kwargs @@ -215,7 +229,7 @@ def process_url_data(self, *args, **kwargs): def get_context_data(self, **kwargs): """ Add action to context. """ - context = super(ActionMixin, self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) context['action'] = self.action @@ -276,9 +290,9 @@ class ActionUserView(ActionTemplateView): """ Base class for subscribe and unsubscribe user views. """ template_name = "newsletter/subscription_%(action)s_user.html" - def process_url_data(self, *args, **kwargs): + def process_url_data(self, request, *args, **kwargs): """ Add confirm to instance attributes. """ - super(ActionUserView, self).process_url_data(*args, **kwargs) + super(ActionUserView, self).process_url_data(request, *args, **kwargs) # confirm is optional kwarg defaulting to False self.confirm = kwargs.get('confirm', False) @@ -287,8 +301,8 @@ def post(self, request, *args, **kwargs): return self.get(request, *args, **kwargs) @method_decorator(login_required) - def dispatch(self, *args, **kwargs): - return super(ActionUserView, self).dispatch(*args, **kwargs) + def dispatch(self, request, *args, **kwargs): + return super(ActionUserView, self).dispatch(request, *args, **kwargs) class SubscribeUserView(ActionUserView): @@ -325,7 +339,7 @@ def get(self, request, *args, **kwargs): _('You are already subscribed to %s.') % self.newsletter ) - return super(SubscribeUserView, self).get(request, *args, **kwargs) + return super().get(request, *args, **kwargs) class UnsubscribeUserView(ActionUserView): @@ -367,22 +381,22 @@ def get(self, request, *args, **kwargs): _('You are not subscribed to %s.') % self.newsletter ) - return super(UnsubscribeUserView, self).get(request, *args, **kwargs) + return super().get(request, *args, **kwargs) class ActionRequestView(ActionFormView): """ Base class for subscribe, unsubscribe and update request views. """ template_name = "newsletter/subscription_%(action)s.html" - def process_url_data(self, *args, **kwargs): + def process_url_data(self, request, *args, **kwargs): """ Add error to instance attributes. """ - super(ActionRequestView, self).process_url_data(*args, **kwargs) + super(ActionRequestView, self).process_url_data(request, *args, **kwargs) self.error = None def get_context_data(self, **kwargs): """ Add error to context. """ - context = super(ActionRequestView, self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) context.update({ 'error': self.error, @@ -418,9 +432,9 @@ def form_valid(self, form): return self.no_email_confirm(form) try: - self.subscription.send_activation_email(action=self.action) + self.subscription.send_activation_email(self.request, action=self.action) - except (SMTPException, socket.error) as e: + except (SMTPException, OSError) as e: logger.exception( 'Error %s while submitting email to %s.', e, self.subscription.email @@ -429,9 +443,9 @@ def form_valid(self, form): # Although form was valid there was error while sending email, # so stay at the same url. - return super(ActionRequestView, self).form_invalid(form) + return super().form_invalid(form) - return super(ActionRequestView, self).form_valid(form) + return super().form_valid(form) class SubscribeRequestView(ActionRequestView): @@ -441,7 +455,7 @@ class SubscribeRequestView(ActionRequestView): def get_form_kwargs(self): """ Add ip to form kwargs for submitted forms. """ - kwargs = super(SubscribeRequestView, self).get_form_kwargs() + kwargs = super().get_form_kwargs() if self.request.method in ('POST', 'PUT'): kwargs['ip'] = self.request.META.get('REMOTE_ADDR') @@ -456,7 +470,7 @@ def dispatch(self, request, *args, **kwargs): kwargs['confirm'] = self.confirm return SubscribeUserView.as_view()(request, *args, **kwargs) - return super(SubscribeRequestView, self).dispatch( + return super().dispatch( request, *args, **kwargs ) @@ -471,7 +485,7 @@ def dispatch(self, request, *args, **kwargs): kwargs['confirm'] = self.confirm return UnsubscribeUserView.as_view()(request, *args, **kwargs) - return super(UnsubscribeRequestView, self).dispatch( + return super().dispatch( request, *args, **kwargs ) @@ -489,14 +503,14 @@ class UpdateSubscriptionView(ActionFormView): form_class = UpdateForm template_name = "newsletter/subscription_activate.html" - def process_url_data(self, *args, **kwargs): + def process_url_data(self, request, *args, **kwargs): """ Add email, subscription and activation_code to instance attributes. """ assert 'email' in kwargs - super(UpdateSubscriptionView, self).process_url_data(*args, **kwargs) + super(UpdateSubscriptionView, self).process_url_data(request, *args, **kwargs) self.subscription = get_object_or_404( Subscription, newsletter=self.newsletter, @@ -515,7 +529,7 @@ def get_initial(self): def get_form_kwargs(self): """ Add instance to form kwargs. """ - kwargs = super(UpdateSubscriptionView, self).get_form_kwargs() + kwargs = super().get_form_kwargs() kwargs['instance'] = self.subscription @@ -530,7 +544,7 @@ def form_valid(self, form): subscription.update(self.action) - return super(UpdateSubscriptionView, self).form_valid(form) + return super().form_valid(form) class SubmissionViewBase(NewsletterMixin): @@ -545,16 +559,16 @@ class SubmissionViewBase(NewsletterMixin): month_format = '%m' day_format = '%d' - def process_url_data(self, *args, **kwargs): + def process_url_data(self, request, *args, **kwargs): """ Use only visible newsletters. """ - kwargs['newsletter_queryset'] = NewsletterListView().get_queryset() + kwargs['newsletter_queryset'] = NewsletterListView(request=request).get_queryset() return super( - SubmissionViewBase, self).process_url_data(*args, **kwargs) + SubmissionViewBase, self).process_url_data(request, *args, **kwargs) def get_queryset(self): """ Filter out submissions for current newsletter. """ - qs = super(SubmissionViewBase, self).get_queryset() + qs = super().get_queryset() qs = qs.filter(newsletter=self.newsletter) @@ -586,7 +600,7 @@ def get_context_data(self, **kwargs): Make sure the actual message is available. """ context = \ - super(SubmissionArchiveDetailView, self).get_context_data(**kwargs) + super().get_context_data(**kwargs) message = self.object.message @@ -603,7 +617,7 @@ def get_context_data(self, **kwargs): context.update({ 'message': message, 'attachment_links': True, - 'site': Site.objects.get_current(), + 'site': Site.objects.get_current(self.request), 'date': self.object.publish_date, 'STATIC_URL': settings.STATIC_URL, 'MEDIA_URL': settings.MEDIA_URL, diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 42af757a..00000000 --- a/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -Django>=2.2.16 -python-card-me<1.0 -ldif3<3.2 -chardet -six -unicodecsv<0.15 -Pillow diff --git a/requirements_test.txt b/requirements_test.txt deleted file mode 100644 index 5aacc714..00000000 --- a/requirements_test.txt +++ /dev/null @@ -1,8 +0,0 @@ -django-imperavi -# TinyMCE above 3 doesn't support Python 3.5 anymore. -# TODO: Remove version freeze when Django 2.2 LTS support is dropped, early 2022. -django-tinymce<3 -pytz -webtest -django-webtest -mock diff --git a/runtests.py b/runtests.py deleted file mode 100755 index e000c087..00000000 --- a/runtests.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python - -import os -import sys - -import django -from django.core.management.commands.test import Command as TestCommand - - -def setup_django(): - """ Setup Django for testing using the test_project directory """ - - test_project_dir = os.path.join(os.path.dirname(__file__), 'test_project') - sys.path.insert(0, test_project_dir) - - os.environ['DJANGO_SETTINGS_MODULE'] = 'test_project.settings' - django.setup() - - -def run_tests(): - """ Run tests via setuptools, all tests with no special options """ - - setup_django() - - # Bypass argument parsing and run the test command manually with minimal args - TestCommand().handle(**{'testrunner': None, 'liveserver': None}) - - sys.exit(0) # TestCommand exits itself on failure, we only exit on success - - -if __name__ == "__main__": - setup_django() - - # Command expects to be called via 'manage.py test' so - # add the extra argument so that it can parse correctly - sys.argv.insert(1, 'test') - - # Run the test command with argv to get all the argument goodies - TestCommand().run_from_argv(sys.argv) diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 5e409001..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[wheel] -universal = 1 diff --git a/setup.py b/setup.py index 07901186..7bfb5ed4 100755 --- a/setup.py +++ b/setup.py @@ -30,21 +30,11 @@ warnings.warn('Could not read README.rst and/or CHANGES.rst') README = None -try: - REQUIREMENTS = open('requirements.txt').read() -except: - warnings.warn('Could not read requirements.txt') - REQUIREMENTS = None - -try: - TEST_REQUIREMENTS = open('requirements_test.txt').read() -except: - warnings.warn('Could not read requirements_test.txt') - TEST_REQUIREMENTS = None setup( name='django-newsletter', - version="1.0b1", + use_scm_version={"version_scheme": "post-release"}, + setup_requires=["setuptools_scm"], description=( 'Django app for managing multiple mass-mailing lists with both ' 'plaintext as well as HTML templates (and pluggable WYSIWYG editors ' @@ -52,26 +42,37 @@ 'the admin interface.' ), long_description=README, - install_requires=REQUIREMENTS, + install_requires=[ + "Django>=2.2.16", + "python-card-me<1.0", + "ldif3<3.2", + "chardet", + "unicodecsv<0.15", + "Pillow", + ], author='Mathijs de Bruin', author_email='mathijs@mathijsfietst.nl', url='http://github.com/jazzband/django-newsletter/', packages=find_packages(exclude=("tests", "test_project")), include_package_data=True, + python_requires='>=3.7', classifiers=[ 'Development Status :: 6 - Mature', 'Environment :: Web Environment', 'Framework :: Django', + 'Framework :: Django :: 2.2', + 'Framework :: Django :: 3.1', + 'Framework :: Django :: 3.2', 'Intended Audience :: Developers', 'License :: OSI Approved :: GNU Affero General Public License v3', 'Operating System :: OS Independent', 'Programming Language :: Python', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3 :: Only', 'Topic :: Utilities' ], - test_suite='runtests.run_tests', - tests_require=TEST_REQUIREMENTS ) diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index a02b7e03..6c1e38d8 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -84,3 +84,5 @@ }, }, } + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/tests/test_admin.py b/tests/test_admin.py index 57d37916..4282b99e 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -6,21 +6,20 @@ from django.contrib import admin as django_admin from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission +from django.contrib.sites.models import Site from django.test import TestCase +from django.urls import reverse from newsletter import admin # Triggers model admin registration from newsletter.admin_utils import make_subscription -from newsletter.compat import reverse from newsletter.models import Message, Newsletter, Submission, Subscription, Attachment, attachment_upload_to -from .utils import AssertLogsMixin - test_files_dir = os.path.join(os.path.dirname(__file__), 'files') -class AdminTestMixin(object): +class AdminTestMixin: def setUp(self): - super(AdminTestMixin, self).setUp() + super().setUp() User = get_user_model() self.password = 'johnpassword' @@ -42,7 +41,7 @@ def setUp(self): message=self.message_with_attachment) -class AdminTestCase(AdminTestMixin, AssertLogsMixin, TestCase): +class AdminTestCase(AdminTestMixin, TestCase): def admin_import_file(self, source_file, ignore_errors=''): """ Upload an address file for import to admin. """ @@ -349,7 +348,7 @@ class SubmissionAdminTests(AdminTestMixin, TestCase): """ Tests for Submission admin. """ def setUp(self): - super(SubmissionAdminTests, self).setUp() + super().setUp() self.add_url = reverse('admin:newsletter_submission_add') self.changelist_url = reverse('admin:newsletter_submission_changelist') @@ -358,7 +357,7 @@ def test_changelist(self): """ Testing submission admin change list display. """ # Assure there's a submission - Submission.from_message(self.message) + Submission.from_message(self.message, Site.objects.get_current()) response = self.client.get(self.changelist_url) self.assertContains( @@ -370,10 +369,11 @@ def test_duplicate_fail(self): """ Test that a message cannot be published twice. """ # Assure there's a submission - Submission.from_message(self.message) + Submission.from_message(self.message, Site.objects.get_current()) response = self.client.post(self.add_url, data={ 'message': self.message.pk, + 'site': Site.objects.get_current().id, 'publish_date_0': '2016-01-09', 'publish_date_1': '07:24', 'publish': 'on', @@ -388,6 +388,7 @@ def test_add(self): response = self.client.post(self.add_url, data={ 'message': self.message.pk, + 'site': Site.objects.get_current().id, 'publish_date_0': '2016-01-09', 'publish_date_1': '07:24', 'publish': 'on', @@ -410,6 +411,7 @@ def test_add_wrongmessage_regression(self): response = self.client.post(self.add_url, data={ 'message': self.message.pk, + 'site': Site.objects.get_current().id, 'publish_date_0': '2016-01-09', 'publish_date_1': '07:24', 'publish': 'on', @@ -433,6 +435,7 @@ def test_add_existing_submission_regression(self): # create submission for third message and add it response = self.client.post(self.add_url, data={ 'message': message.pk, + 'site': Site.objects.get_current().id, 'publish_date_0': '2020-10-30', 'publish_date_1': '07:24', 'publish': 'on', @@ -445,6 +448,7 @@ def test_add_existing_submission_regression(self): response = self.client.post( reverse('admin:newsletter_submission_change', kwargs={'object_id': 3}), data={ 'message': message.pk, + 'site': Site.objects.get_current().id, 'publish_date_0': '2020-10-30', 'publish_date_1': '07:24', 'publish': 'on', @@ -456,7 +460,7 @@ def test_add_existing_submission_regression(self): class ArticleInlineTests(TestCase): - class MockSorlAdminImageMixin(object): + class MockSorlAdminImageMixin: def __init__(self): self.parent_class = 'sorl-thumbnail' diff --git a/tests/test_fields.py b/tests/test_fields.py index 10f53103..50798a88 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -10,11 +10,11 @@ from newsletter import fields class FieldsTestCase(TestCase): - class MockSorlThumbnailImageField(object): + class MockSorlThumbnailImageField: def __init__(self): self.parent_class = 'sorl-thumbnail' - class MockEasyThumbnailsImageField(object): + class MockEasyThumbnailsImageField: def __init__(self): self.parent_class = 'easy-thumbnails' diff --git a/tests/test_mailing.py b/tests/test_mailing.py index 9a7e3b53..a75a82b2 100644 --- a/tests/test_mailing.py +++ b/tests/test_mailing.py @@ -1,16 +1,14 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import itertools import os -import mock -import six +from unittest import mock import unittest from datetime import timedelta +from django.contrib.sites.models import Site from django.core import mail +from django.core.exceptions import ValidationError from django.utils.timezone import now @@ -35,14 +33,17 @@ def get_newsletter_kwargs(self): 'sender': 'Test Sender', 'email': 'test@testsender.com' } + + def get_newsletter_sites(self): + return get_default_sites() + + def get_site(self) -> Site: + return Site.objects.get_current() def setUp(self): self.n = Newsletter(**self.get_newsletter_kwargs()) self.n.save() - try: - self.n.site.set(get_default_sites()) - except AttributeError: # Django < 1.10 - self.n.site = get_default_sites() + self.n.site.set(self.get_newsletter_sites()) self.m = Message(title='Test message', newsletter=self.n, @@ -68,7 +69,7 @@ def send_email(self, action): if action == 'message': # Create submission - sub = Submission.from_message(self.m) + sub = Submission.from_message(self.m, Site.objects.get_current()) sub.prepared = True sub.publish_date = now() - timedelta(seconds=1) sub.save() @@ -77,7 +78,7 @@ def send_email(self, action): Submission.submit_queue() else: for subscriber in self.n.get_subscriptions(): - subscriber.send_activation_email(action) + subscriber.send_activation_email(Site.objects.get_current(), action) class ArticleTestCase(MailingTestCase): @@ -110,7 +111,7 @@ def test_sortorder_defaults(self): class MessageTestCase(MailingTestCase): def test_message_str(self): m1 = Message(title='Test message', slug='test-message') - self.assertEqual(six.text_type(m1), "Test message in Test newsletter") + self.assertEqual(str(m1), "Test message in Test newsletter") m2 = Message.objects.create( title='Test message str', @@ -119,7 +120,7 @@ def test_message_str(self): ) self.assertEqual( - six.text_type(m2), "Test message str in Test newsletter" + str(m2), "Test message str in Test newsletter" ) @@ -132,7 +133,9 @@ def test_subscription(self): def test_submission_from_message(self): """ Test creating a submission from a message. """ - sub = Submission.from_message(self.m) + sub = Submission.from_message(self.m, self.get_site()) + unsubscribe_link = sub.extra_headers['List-Unsubscribe'] + self.assertIn(self.get_site().domain, unsubscribe_link) subscriptions = sub.subscriptions.all() self.assertEqual(set(subscriptions), {self.s, self.s2}) @@ -147,7 +150,7 @@ def test_submission_subscribed(self): self.s.subscribed = False self.s.save() - sub = Submission.from_message(self.m) + sub = Submission.from_message(self.m, self.get_site()) subscriptions = sub.subscriptions.all() self.assertEqual(list(subscriptions), [self.s2]) @@ -158,7 +161,7 @@ def test_submission_unsubscribed(self): self.s.unsubscribed = True self.s.save() - sub = Submission.from_message(self.m) + sub = Submission.from_message(self.m, self.get_site()) subscriptions = sub.subscriptions.all() self.assertEqual(list(subscriptions), [self.s2]) @@ -170,17 +173,57 @@ def test_submission_unsubscribed_unactivated(self): self.s.unsubscribed = True self.s.save() - sub = Submission.from_message(self.m) + sub = Submission.from_message(self.m, self.get_site()) subscriptions = sub.subscriptions.all() self.assertEqual(list(subscriptions), [self.s2]) +class CreateSubmissionMultiSitesTestCase(CreateSubmissionTestCase): + sites = "somerandom.com anotherrandom.net somethingelse.org anotheronegood.dev 1somethingelse.org " \ + "2anotheronegood.dev 32anotheronegood3.dev".split() + + def add_site(self, domain: str) -> Site: + site = Site() + site.domain = domain + site.name = "Test " + domain + site.save() + return site + + def setUp(self): + for domain in self.sites: + self.add_site(domain) + + super().setUp() + + def get_newsletter_sites(self): + domains = self.sites[1:-2] + return Site.objects.filter(domain__in=domains) + + def get_site(self) -> Site: + return Site.objects.get(domain=self.sites[2]) + + def test_setup(self): + count_sites = Site.objects.all().count() + # example.com is created by default + self.assertEqual(len(self.sites) + 1, count_sites) + self.assertGreater(len(self.get_newsletter_sites()), 2) + self.assertEqual(len(self.get_newsletter_sites()), len(self.n.site.all())) + + def test_submission_site_validation(self): + """ Test creating a submission from a message with site provided. """ + + callback = lambda: Submission.from_message(self.m, Site.objects.get(domain=self.sites[0])) + self.assertRaises(ValidationError, callback) + callback = lambda: Submission.from_message(self.m, None) + self.assertRaises(ValidationError, callback) + + class SubmitSubmissionTestCase(MailingTestCase): def setUp(self): - super(SubmitSubmissionTestCase, self).setUp() + super().setUp() - self.sub = Submission.from_message(self.m) + self.sub = Submission.from_message(self.m, self.get_site()) self.sub.save() def test_submission(self): @@ -276,7 +319,7 @@ def test_management_command(self): class SubscriptionTestCase(UserTestCase, MailingTestCase): def setUp(self): - super(SubscriptionTestCase, self).setUp() + super().setUp() self.us = Subscription(user=self.user, newsletter=self.n) self.us.save() @@ -325,7 +368,7 @@ def test_subscribe_unsubscribe(self): self.assertNotEqual(s.subscribe_date, old_subscribe_date) -class AllEmailsTestsMixin(object): +class AllEmailsTestsMixin: """ Mixin for testing properties of sent e-mails for all message types. """ def assertSentEmailIsProper(self, action): @@ -382,7 +425,7 @@ def get_newsletter_kwargs(self): with send_html = True. """ - kwargs = super(HtmlEmailsTestCase, self).get_newsletter_kwargs() + kwargs = super().get_newsletter_kwargs() kwargs.update(send_html=True) return kwargs @@ -412,7 +455,7 @@ def get_newsletter_kwargs(self): with send_html = False. """ - kwargs = super(TextOnlyEmailsTestCase, self).get_newsletter_kwargs() + kwargs = super().get_newsletter_kwargs() kwargs.update(send_html=False) return kwargs @@ -455,12 +498,12 @@ class TemplateOverridesTestCase(MailingTestCase, AllEmailsTestsMixin): def get_newsletter_kwargs(self): """ - Update keyword arguments for instanciating the newsletter + Update keyword arguments for instantiating the newsletter so that slug corresponds to one for which template overrides exists and make sure e-mails will be sent with text and HTML versions. """ - kwargs = super(TemplateOverridesTestCase, self).get_newsletter_kwargs() + kwargs = super().get_newsletter_kwargs() kwargs.update(slug='test-newsletter-with-overrides', send_html=True) diff --git a/tests/test_web.py b/tests/test_web.py index 97364349..7187eea2 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -6,16 +6,17 @@ from datetime import datetime, timedelta +from django.contrib.sites.models import Site from django.core import mail from django.contrib.auth import get_user_model from django.utils import timezone -from django.utils.encoding import force_text +from django.utils.encoding import force_str from django.test.utils import override_settings +from django.urls import reverse from newsletter.models import ( Newsletter, Subscription, Submission, Message, get_default_sites ) -from newsletter.compat import reverse from newsletter.forms import UpdateForm from .utils import MailTestCase, UserTestCase, WebTestCase, ComparingTestCase @@ -193,7 +194,7 @@ def test_listform(self): for form in formset.forms: self.assertTrue( form.instance.newsletter in self.newsletters, - "%s not in %s" % (form.instance.newsletter, self.newsletters) + f"{form.instance.newsletter} not in {self.newsletters}" ) self.assertContains(response, form['id']) self.assertContains(response, form['subscribed']) @@ -338,7 +339,7 @@ def setUp(self): kwargs={'newsletter_slug': self.n.slug, 'action': 'unsubscribe'}) - super(SubscribeTestCase, self).setUp() + super().setUp() def test_urls(self): # TODO: is performing this test in each subclass @@ -458,7 +459,7 @@ def test_unsubscribe_not_subscribed_view(self): self.assertIn( 'You are not subscribed to', - force_text(list(response.context['messages'])[0]) + force_str(list(response.context['messages'])[0]) ) def test_unsubscribe_post(self): @@ -560,7 +561,7 @@ def test_subscribe_request_post(self): self.assertEqual(len(mail.outbox), 1) activate_url = subscription.subscribe_activate_url() - full_activate_url = 'http://%s%s' % (self.site.domain, activate_url) + full_activate_url = f'http://{self.site.domain}{activate_url}' self.assertEmailContains(full_activate_url) @@ -895,7 +896,7 @@ def test_unsubscribe_request_post(self): self.assertEqual(len(mail.outbox), 1) activate_url = subscription.unsubscribe_activate_url() - full_activate_url = 'http://%s%s' % (self.site.domain, activate_url) + full_activate_url = f'http://{self.site.domain}{activate_url}' self.assertEmailContains(full_activate_url) @@ -1034,7 +1035,7 @@ def test_update_request_post(self): self.assertEqual(len(mail.outbox), 1) activate_url = subscription.update_activate_url() - full_activate_url = 'http://%s%s' % (self.site.domain, activate_url) + full_activate_url = f'http://{self.site.domain}{activate_url}' self.assertEmailContains(full_activate_url) @@ -1250,7 +1251,7 @@ class InvisibleAnonymousSubscribeTestCase(AnonymousSubscribeTestCase): """ def setUp(self): - super_obj = super(InvisibleAnonymousSubscribeTestCase, self) + super_obj = super() super_obj.setUp() # Make newsletter invisible @@ -1268,7 +1269,7 @@ class InvisibleUserSubscribeTestCase(UserSubscribeTestCase): """ def setUp(self): - super_obj = super(InvisibleUserSubscribeTestCase, self) + super_obj = super() super_obj.setUp() # Make newsletter invisible @@ -1298,7 +1299,7 @@ def setUp(self): self.assertTrue(message.html_template) # Create a submission - self.submission = Submission.from_message(message) + self.submission = Submission.from_message(message, Site.objects.get_current()) def test_archive_invisible(self): """ Test whether an invisible newsletter is indeed not shown. """ @@ -1420,7 +1421,7 @@ def test_archive_timezone_regression(self): self.test_archive_detail() -class ActionTemplateViewMixin(object): +class ActionTemplateViewMixin: """ Mixin for testing requests to urls for all three actions. """ def get_action_url(self, action): diff --git a/tests/utils.py b/tests/utils.py index a8456b4f..99d6617d 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,6 +1,4 @@ -from contextlib import contextmanager import logging -logger = logging.getLogger(__name__) import smtplib @@ -12,74 +10,19 @@ from django.test import TestCase - from django.template import loader, TemplateDoesNotExist from django_webtest import WebTest -class AssertLogsMixin: - """Mixin to enable assertLogs method in Python 2.7. +logger = logging.getLogger(__name__) - patch_logger has similar functionality for Python 2.7, but is - removed in Django 3.0. This mixin reworks patch_logger so that - the assertLogs method can be used for any supported Django - version. - """ - # TODO: Remove use of mixin when Django 1.11 support dropped - def assertLogs(self, logger=None, level=None): - # Use assertLogs context manager if present - try: - from unittest.case import _AssertLogsContext - - return _AssertLogsContext(self, logger, level) - except ImportError: - # Fallback if Django version does not support assertLogs - import logging - from django.test.utils import patch_logger - - class PatchLoggerResponse: - """Object to mimic AssertLogsContext response.""" - def __init__(self, messages): - self.output = messages - - @contextmanager - def patch_logger(logger_name, log_level, log_kwargs=False): - """Replicating patch_logger functionality from Django 1.11. - - Cannot use original Django patch_logger because of how - it returns its response. Have copied and modified it - to return an object that mimics the assertLogs response. - """ - logger_response = PatchLoggerResponse([]) - - def replacement(msg, *args, **kwargs): - call = msg % args - logger_response.output.append((call, kwargs) if log_kwargs else call) - - logger = logging.getLogger(logger_name) - orig = getattr(logger, log_level) - setattr(logger, log_level, replacement) - - try: - yield logger_response - finally: - setattr(logger, log_level, orig) - - if len(logger_response.output) == 0: - raise self.failureException( - "no logs of level {} or higher triggered on {}".format( - log_level, logger_name - ) - ) - - return patch_logger(logger, level.lower()) class WebTestCase(WebTest): def setUp(self): self.site = Site.objects.get_current() - super(WebTestCase, self).setUp() + super().setUp() def assertInContext(self, response, variable, instance_of=None, value=None): @@ -98,7 +41,7 @@ def assertInContext(self, response, variable, self.assertEqual(instance, value) -class MailTestCase(AssertLogsMixin, TestCase): +class MailTestCase(TestCase): def get_email_list(self, email): if email: return (email,) @@ -160,9 +103,9 @@ def assertEmailHasHeader(self, header, content=None, email=None): self.assertEqual(my_email.extra_headers[header], content) -class UserTestCase(AssertLogsMixin, TestCase): +class UserTestCase(TestCase): def setUp(self): - super(UserTestCase, self).setUp() + super().setUp() User = get_user_model() self.password = 'johnpassword' @@ -185,7 +128,7 @@ def tearDown(self): self.user.delete() -class ComparingTestCase(AssertLogsMixin, TestCase): +class ComparingTestCase(TestCase): def assertLessThan(self, value1, value2): self.assertTrue(value1 < value2) diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..d00ab7d2 --- /dev/null +++ b/tox.ini @@ -0,0 +1,43 @@ +[tox] +envlist = + py{37,38,39}-dj{22,31,32} + py{38,39,310}-djmain + +[testenv] +deps = + coverage + django-imperavi + # TinyMCE above 3 doesn't support Python 3.5 anymore. + # TODO: Remove version freeze when Django 2.2 LTS support is dropped, early 2022. + django-tinymce<3 + pytz + webtest + django-webtest + dj22: Django>=2.2,<3.0 + dj31: Django>=3.1,<3.2 + dj32: Django>=3.2,<3.3 + djmain: https://github.com/django/django/archive/main.tar.gz +usedevelop = True +ignore_outcome = + djmain: True +commands = + coverage run {envbindir}/django-admin test + coverage report + coverage xml +setenv = + DJANGO_SETTINGS_MODULE=test_project.settings + PYTHONPATH={toxinidir}/test_project + +[gh-actions] +python = + 3.7: py37 + 3.8: py38 + 3.9: py39 + 3.10: py310 + +[gh-actions:env] +DJANGO = + 2.2: dj22 + 3.1: dj31 + 3.2: dj32 + main: djmain