Repository: jazzband/django-newsletter (language: python)

Issue:
Implement Jazzband guidelines for django-newsletter
This issue tracks the implementation of the [Jazzband guidelines](https://jazzband.co/about/guidelines) for the project [django-newsletter](https://github.com/jazzband/django-newsletter)

It was initiated by @dokterbob who was automatically assigned in addition to the [Jazzband roadies](https://jazzband.co/roadies).

See the [TODO list](#todos) below for the generally required tasks, but feel free to update it in case the project requires it.

Feel free to ping a [Jazzband roadie](https://jazzband.co/roadies) if you have any question.

## TODOs<a name="todos"></a>

- [x] Fix all links in the docs (and README file etc) from old to new repo
- [x] Add the [Jazzband badge](https://jazzband.co/about/guidelines#badges) to the `README` file
- [x] Add the [Jazzband contributing guideline](https://jazzband.co/about/guidelines#contributing-guidelines) to the `CONTRIBUTING.md` or `CONTRIBUTING.rst` file
- [x] Check if continuous testing works (e.g. [Travis CI](https://travis-ci.org/), [CircleCI](https://circleci.com), [AppVeyor](https://www.appveyor.com), etc)
- [x] Check if test coverage services work (e.g. [Coveralls](https://coveralls.io), [Codecov](https://codecov.io), etc)
- [x] Add `jazzband` account to [PyPI](https://pypi.org/) project as maintainer role (e.g. URL: https://pypi.org/manage/project/django-newsletter/collaboration/)
- [x] Add `jazzband-bot` as maintainer to the [Read the Docs](https://readthedocs.org/) project (e.g. URL: https://readthedocs.org/dashboard/django-newsletter/users/)
- [x] Add incoming GitHub webhook integration to Read the Docs project (e.g. URL: https://readthedocs.org/dashboard/django-newsletter/integrations/)
- [x] Fix project URL in [GitHub project](https://github.com/jazzband/django-newsletter) description
- [x] Review project if other services are used and port them to Jazzband
- [ ] Decide who is project lead for the project (if at all)
- [ ] Set up CI for Jazzband project releases if needed and open ticket if yes


## Project details

<table>
  <caption></caption>
  <tr>
    <td>Description</td>
    <td>An email newsletter application for the Django web application framework, including an extended admin interface, web (un)subscription, dynamic e-mail templates, an archive and HTML email support.</td>
  </tr>
  <tr>
    <td>Homepage</td>
    <td></td>
  </tr>
  <tr>
    <td>Stargazers</td>
    <td>540</td>
  </tr>
  <tr>
    <td>Open issues</td>
    <td>33</td>
  </tr>
  <tr>
    <td>Forks</td>
    <td>158</td>
  </tr>
  <tr>
    <td>Default branch</td>
    <td>master</td>
  </tr>
  <tr>
    <td>Is a fork</td>
    <td>False</td>
  </tr>
  <tr>
    <td>Has Wiki</td>
    <td>False</td>
  </tr>
  <tr>
    <td>Has Pages</td>
    <td>False</td>
  </tr>
</table>



Discussion / hints:
@jezdez Long time no seen! Thanks for setting up this Jazzband thing - it’s a great way to keep projects and the community alive!

As I’ve not been very active in the Django community for a long while, I’ve decided to transfer my baby, ‘django-newsletter’ to the Jazzband. Must admit, it’s an excellent idea to make the maintenance less dependent on one person! There’s pull requests I haven’t tended to in months..

That having been said, I am realising that giving full write access to the ~ 800 members of this band to my repo has the risk of dissolving the responsibilty so much that it might actually reduce participation. I’ve noticed there’s already separate teams for pip-tools and Grumpy - would you be okay creating a separate team for django-newsletter as well?

I am very much willing to give access to any members of the Jazzband that ask for it, there are several contributors that have already facilitated the maintenance of the package, but I feel that if 800 people share this load, none of them might feel a final sense of responsibility. However, in the case of permanent of temporary unavailability of the current maintainers, other members of the jazzband, or even the band as a whole might take over so the project would not be abandoned.
Hey @dokterbob! Thanks for reaching out to me, I appreciate your words regarding Jazzband. It's my hope that by lowering the barriers to maintenance it basically becomes less of a burden for those that started projects and allow more people to contribute.

For that reason Jazzband uses an unusual practice in the age of "social coding", allowing everyone to participate who is willing since it's my experience that there are more people willing, than not. While it's understandable that GitHub itself can't be "open by default", Jazzband has a much narrower scope for Python projects. Those projects share best practices that are much easier to partially or fully automate. E.g. the way we've come to set up continuous integration with the [Jazzband package index](https://jazzband.co/about/releases) allows us to reduce the risk of accidental or malicious releases to PyPI without prior review by "project leads". Similarly we've standardized loosely on other aspects like documentation and code quality testing. All with the single goal to reduce the friction we've all seen when either maintaining an own or contributing to a 3rd party project.

Jazzband is indeed growing and has reached a size that I did not expect to happen when I started it 5 years ago. So your concern about a potential lack of responsibility is certainly there and I've been monitoring for it for a while. I can anecdotally say though that while some members may leave the organization again (e.g. in total ~1100 people joined in the past, ~800 remain), and while there are many inactive members, the risk of loss of contributors is a bigger threat to long-term maintainability than an open-door policy. In fact I've seen many people start to contribute in earnest for the first time because of having the same permission level as everyone else. It is also encouraging to new contributors and reduces stress for original authors. In other words, it's okay to not maintain something alone.

So I strongly believe it's essential to keep the "door open" for new people who finding interest, time and energy to contribute to one or more projects. We have all different reasons why we're doing this of course, but the end result is the same.

As such I'm afraid that reducing the number of people that would have access to django-newsletter would be contrary to a very central idea of Jazzband and I must decline your wish for now. That said, creating a GitHub team to simplify coordination and communication is certainly fine, in fact it's been a long term feature request to add teams for each project automatically so people may "join" them via the website or GitHub and get a bit more updates (or other optional features) and ease communication via GitHub's discussion system. Alas I haven't finalized the feature (some progress e.g. https://github.com/jazzband-roadies/website/pull/489).

I would like to encourage you to try out the open model, but I would fully understand if you were to decide to transfer the repo back. Please let me know either way. Thank you!
FWIW, I'm with jezdez here. Having maintainers who care is important, but I don't think that's related to the number of people having access to the repo.
Thanks for the feedback @jezdez and @claudep. I am not too worried for malicious behaviour so having many people with access is good.

However, I would really appreciate if you could manually create us a group to facilitate the communication, basically to know who's currently actively involved, to be able to reach out to them and, perhaps, also to facilitate updates. I guess the main thing is, whom of these 800 people might I contact if I have questions - rather than it just being myself as being project lead.

This is how we've been functioning in the past, doing @-mentions for active contributors to see if they have an opinion about things. If we can @-mention the team, it might facilitate things. 

What do you think?
I opened PRs for the first three ToDos. For the other ToDos account acccess (Github, RTD, PyPI) is needed.
Thanks @frennkie ! I'll see if I can tend to the other issues as soon as I can (next rainy day, probably, later this week :p)
Ref: #353 

I'm rather saddened to see that, somehow, my rights to change settings in the repo have been revoked and would very much appreciate having them back. I don't feel all admin rights reside with a single person resolve the issues that the jazzband was created to solve. Hope this was unintended. @jezdez 
In addition, I would propose a co-leadership of the project between myself and @frennkie.
@dokterbob Sadly the GitHub permission system doesn't provide a way to select only certain permissions, and there are only basically write and maintain permission levels: https://docs.github.com/en/free-pro-team@latest/github/setting-up-and-managing-organizations-and-teams/repository-permission-levels-for-an-organization

I know that "maintain" seems sensible for project leads in most of the cases, but sadly having the ability to act on security or conduct issues means that I can't give you access to the settings back. I hope you understand that it's a legitimate concern in an open GitHub organization such as this and forgive the extra steps to open tickets for settings updates. Obviously if you disagree (which I would totally understand) I'm happy to transfer the repo back to you until we can find a better way of dealing with this. Ideas are welcome to handle it better btw!

Your feedback regarding the bus factor of the roadies is spot-on, it's being tracked in https://github.com/jazzband-roadies/help/issues/196.
@dokterbob Having permissions revoked on editing Settings is something you get used to. I co-maintain [django-analytical](https://github.com/jazzband/django-analytical) and it felt weird not be allowed to inspect, let alone change settings related to things you care about.

But, yeah, now it's all about reviewing PRs and engage in & triage issues. At least, I can create feature branches directly on the repository (on any Jazzband repository, I guess), so I don't need to fork into my personal namespace. But I can get the changes merged only if I manage to have at least one Jazzband member review and accept the PR.

It's actually a good system. Having a lot of automation in place and a uniform approach to managing similar projects. It's nice. :teddy_bear: 

Fix patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..fb049935
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,53 @@
+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.8
+
+      - name: Get pip cache dir
+        id: pip-cache
+        run: |
+          echo "::set-output name=dir::$(pip cache dir)"
+
+      - name: Cache
+        uses: actions/cache@v2
+        with:
+          path: ${{ steps.pip-cache.outputs.dir }}
+          key: release-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }}
+          restore-keys: |
+            release-
+
+      - 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/.gitignore b/.gitignore
index bea050bb..089a1a33 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,5 @@ django_setuptest*
 docs/_*
 .idea/
 venv/
+coverage.xml
+.tox
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/.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/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 7ed6d44c..00000000
--- a/Pipfile
+++ /dev/null
@@ -1,24 +0,0 @@
-[[source]]
-url = "https://pypi.org/simple"
-verify_ssl = true
-name = "pypi"
-
-[packages]
-django-imperavi = "*"
-django-tinymce = "*"
-pytz = "*"
-django-webtest = "*"
-WebTest = "*"
-python-card-me = "<1.0"
-ldif3 = "<3.2"
-chardet = "*"
-surlex = ">=0.2.0"
-sorl-thumbnail = ">=12.6.3"
-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 87d90c4d..d7582832 100644
--- a/README.rst
+++ b/README.rst
@@ -5,11 +5,12 @@ 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/jazzband/django-newsletter/master.svg
-    :target: http://travis-ci.org/jazzband/django-newsletter
+.. image:: https://github.com/jazzband/django-newsletter/workflows/Test/badge.svg
+   :target: https://github.com/jazzband/django-newsletter/actions
+   :alt: GitHub Actions
 
-.. image:: https://coveralls.io/repos/github/jazzband/django-newsletter/badge.svg?branch=master
-	:target: https://coveralls.io/github/jazzband/django-newsletter?branch=master
+.. 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/
@@ -58,7 +59,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 <http://travis-ci.org/jazzband/django-newsletter>`_.
+`GitHub Actions <https://github.com/jazzband/django-newsletter/actions>`_.
 
 Contributing
 =============
diff --git a/docs/conf.py b/docs/conf.py
index 45ffb757..0d70b417 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -11,7 +11,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'
@@ -80,10 +83,9 @@
 # |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.
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/admin_utils.py b/newsletter/admin_utils.py
index 18b9a7e5..51299075 100644
--- a/newsletter/admin_utils.py
+++ b/newsletter/admin_utils.py
@@ -2,7 +2,7 @@
 
 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
 
@@ -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/requirements.txt b/requirements.txt
deleted file mode 100644
index fd00206b..00000000
--- a/requirements.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-Django>=2.2.16
-python-card-me<1.0
-ldif3<3.2
-chardet
-unicodecsv<0.15
-Pillow
diff --git a/setup.py b/setup.py
index 07901186..de773439 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,7 +42,14 @@
         '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/',
@@ -66,12 +63,11 @@
         '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',
         'Programming Language :: Python :: 3.6',
         'Programming Language :: Python :: 3.7',
         'Programming Language :: Python :: 3.8',
+        'Programming Language :: Python :: 3.9',
         'Topic :: Utilities'
     ],
-    test_suite='runtests.run_tests',
-    tests_require=TEST_REQUIREMENTS
 )
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 00000000..b32407cc
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,44 @@
+[tox]
+envlist =
+    py{36,37,38,39}-dj{22,30,31}
+    py{38,39}-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
+    mock
+    dj22: Django>=2.2,<3.0
+    dj30: Django>=3.0,<3.1
+    dj31: Django>=3.1,<3.2
+    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.6: py36
+    3.7: py37
+    3.8: py38
+    3.9: py39
+
+[gh-actions:env]
+DJANGO =
+    2.2: dj22
+    3.0: dj30
+    3.1: dj31
+    main: djmain


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 00000000..6765bee9
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,52 @@
+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.6', '3.7', '3.8', '3.9']
+        django-version: ['2.2', '3.0', '3.1', 'main']
+
+    steps:
+    - uses: actions/checkout@v2
+
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v2
+      with:
+        python-version: ${{ matrix.python-version }}
+
+    - name: Get pip cache dir
+      id: pip-cache
+      run: |
+        echo "::set-output name=dir::$(pip cache dir)"
+
+    - name: Cache
+      uses: actions/cache@v2
+      with:
+        path: ${{ steps.pip-cache.outputs.dir }}
+        key:
+          ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }}
+        restore-keys: |
+          ${{ matrix.python-version }}-v1-
+
+    - 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/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/tests/test_admin.py b/tests/test_admin.py
index 38431594..6ae80fbf 100644
--- a/tests/test_admin.py
+++ b/tests/test_admin.py
@@ -13,8 +13,6 @@
 from newsletter.admin_utils import make_subscription
 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')
 
 
@@ -42,7 +40,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. """
 
diff --git a/tests/test_web.py b/tests/test_web.py
index 33fc058f..80fe25cf 100644
--- a/tests/test_web.py
+++ b/tests/test_web.py
@@ -9,7 +9,7 @@
 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
 
@@ -458,7 +458,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):
diff --git a/tests/utils.py b/tests/utils.py
index 450f921f..76dd8904 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -12,69 +12,11 @@
 
 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.
-
-        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()
@@ -98,7 +40,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,7 +102,7 @@ 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().setUp()
 
@@ -185,7 +127,7 @@ def tearDown(self):
         self.user.delete()
 
 
-class ComparingTestCase(AssertLogsMixin, TestCase):
+class ComparingTestCase(TestCase):
     def assertLessThan(self, value1, value2):
         self.assertTrue(value1 < value2)