diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..9019475 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 100 +exclude=*/media/*,*/migrations/*,secret* \ No newline at end of file diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..db00ca9 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,11 @@ +[settings] +line_length=100 +multi_line_output=3 +include_trailing_comma=true +skip=tjdests/media +skip_glob=*/migrations/*,secret* + +known_django=django +known_tjdests=tjdests +default_section=THIRDPARTY +sections=FUTURE,STDLIB,THIRDPARTY,DJANGO,TJDESTS,LOCALFOLDER \ No newline at end of file diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..78b430d --- /dev/null +++ b/mypy.ini @@ -0,0 +1,9 @@ +[mypy] +ignore_missing_imports = True +plugins = mypy_django_plugin.main + +[mypy.plugins.django-stubs] +django_settings_module = tjdests.settings + +[mypy-tjdests.apps.*.migrations.*] +ignore_errors = True \ No newline at end of file diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..61067aa --- /dev/null +++ b/pylintrc @@ -0,0 +1,15 @@ +[MASTER] +init-hook=import sys; sys.path.insert(0, 'tjdests/apps') + +load-plugins=pylint_django + +[MAIN] +disable=missing-docstring,no-else-return,no-else-raise,bad-continuation,duplicate-code,too-many-branches,too-many-nested-blocks,too-many-locals,too-many-statements,too-many-public-methods +max-line-length=100 +ignore=media,migrations +ignore-patterns=secret* +django-settings-module=tjdests.settings + +good-names=i,j,j,ex,ch,fd,T,_ + +const-rgx=(([a-zA-Z_][a-zA-Z0-9_]*)|(__.*__))$ \ No newline at end of file diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..7499ce0 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# MIT License +# +# Copyright (c) 2017 The TJ Director Development Team +# Copyright (c) 2019 The TJHSST Director 4.0 Development Team & Contributors +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +cd "$(dirname -- "$(dirname -- "$(readlink -f "$0")")")" + +for cmd in flake8 isort mypy pylint; do + if [[ ! -x "$(which "$cmd")" ]]; then + echo "Could not find $cmd. Please make sure that flake8, isort, mypy, and pylint are all installed." + exit 1 + fi +done + +flake8 tjdests && isort --check tjdests && mypy tjdests && pylint tjdests \ No newline at end of file diff --git a/tjdests/apps/authentication/forms.py b/tjdests/apps/authentication/forms.py index f4d7400..ebad106 100644 --- a/tjdests/apps/authentication/forms.py +++ b/tjdests/apps/authentication/forms.py @@ -1,5 +1,6 @@ from crispy_forms.helper import FormHelper from crispy_forms.layout import Submit + from django import forms from django.contrib.auth import password_validation @@ -14,22 +15,22 @@ class TOSForm(forms.Form): accept_tos = forms.BooleanField( required=True, - label="I accept the terms of the GNU Affero General Public License as displayed above," - " and I understand that the terms that provide this software WITHOUT ANY WARRANTY;" - " without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.", + label="I accept the terms of the GNU Affero General Public License as displayed above, " + "and I understand that the terms that provide this software WITHOUT ANY WARRANTY; " + "without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.", ) password = forms.CharField(widget=forms.PasswordInput, required=True) - password_confirm = forms.CharField( - widget=forms.PasswordInput, required=True) + password_confirm = forms.CharField(widget=forms.PasswordInput, required=True) understand_no_reset = forms.BooleanField( required=True, - label="I understand that there is NO PASSWORD RESET functionality once I no longer have access to Ion.", + label="I understand that there is NO PASSWORD RESET " + "functionality once I no longer have access to Ion.", ) def clean(self): - cleaned_data = super(TOSForm, self).clean() + cleaned_data = super().clean() password1 = cleaned_data.get("password") password2 = cleaned_data.get("password_confirm") diff --git a/tjdests/apps/authentication/migrations/0001_initial.py b/tjdests/apps/authentication/migrations/0001_initial.py index 03e4b3c..8cf571b 100644 --- a/tjdests/apps/authentication/migrations/0001_initial.py +++ b/tjdests/apps/authentication/migrations/0001_initial.py @@ -25,8 +25,7 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("password", models.CharField( - max_length=128, verbose_name="password")), + ("password", models.CharField(max_length=128, verbose_name="password")), ( "last_login", models.DateTimeField( @@ -101,8 +100,7 @@ class Migration(migrations.Migration): ("is_senior", models.BooleanField(default=False)), ( "publish_data", - models.BooleanField( - default=False, verbose_name="Publish my data"), + models.BooleanField(default=False, verbose_name="Publish my data"), ), ("biography", models.TextField(blank=True)), ], diff --git a/tjdests/apps/authentication/models.py b/tjdests/apps/authentication/models.py index 9030c56..d8d6f6a 100644 --- a/tjdests/apps/authentication/models.py +++ b/tjdests/apps/authentication/models.py @@ -1,7 +1,7 @@ from django.contrib.auth.models import AbstractUser from django.db import models -from ..destinations.models import Decision, TestScore +from ..destinations.models import Decision class User(AbstractUser): diff --git a/tjdests/apps/authentication/oauth.py b/tjdests/apps/authentication/oauth.py index 74b2fa8..10374b2 100644 --- a/tjdests/apps/authentication/oauth.py +++ b/tjdests/apps/authentication/oauth.py @@ -1,14 +1,14 @@ -from django.conf import settings from social_core.backends.oauth import BaseOAuth2 +from django.conf import settings -class IonOauth2(BaseOAuth2): + +class IonOauth2(BaseOAuth2): # pylint: disable=abstract-method name = "ion" AUTHORIZATION_URL = "https://ion.tjhsst.edu/oauth/authorize" ACCESS_TOKEN_URL = "https://ion.tjhsst.edu/oauth/token" ACCESS_TOKEN_METHOD = "POST" - EXTRA_DATA = [("refresh_token", "refresh_token", True), - ("expires_in", "expires")] + EXTRA_DATA = [("refresh_token", "refresh_token", True), ("expires_in", "expires")] def get_scope(self): return ["read"] diff --git a/tjdests/apps/authentication/views.py b/tjdests/apps/authentication/views.py index 6f2807a..717e9bb 100644 --- a/tjdests/apps/authentication/views.py +++ b/tjdests/apps/authentication/views.py @@ -6,7 +6,6 @@ from django.http import HttpRequest, HttpResponse from django.shortcuts import redirect, render from django.urls import reverse -from tjdests.apps.authentication.decorators import require_accept_tos from tjdests.apps.authentication.forms import TOSForm diff --git a/tjdests/apps/destinations/models.py b/tjdests/apps/destinations/models.py index 6ca379f..7caee07 100644 --- a/tjdests/apps/destinations/models.py +++ b/tjdests/apps/destinations/models.py @@ -4,8 +4,7 @@ from django.db import models class College(models.Model): """Represents a college.""" - ceeb_code = models.CharField( - max_length=10, null=False, verbose_name="CEEB Code") + ceeb_code = models.CharField(max_length=10, null=False, verbose_name="CEEB Code") name = models.CharField(max_length=250, null=False, blank=False) location = models.CharField(max_length=250, null=False, blank=False) @@ -58,12 +57,14 @@ class Decision(models.Model): (DENY, "Denied"), ] - admission_status = models.CharField( - max_length=20, choices=ADMIT_TYPE_CHOICES) + admission_status = models.CharField(max_length=20, choices=ADMIT_TYPE_CHOICES) college = models.ForeignKey(College, on_delete=models.CASCADE) def __str__(self): - return f"{self.college.name} - {self.get_decision_type_display()}: {self.get_admission_status_display()}" + return ( + f"{self.college.name} - {self.get_decision_type_display()}: " + f"{self.get_admission_status_display()}" + ) class TestScore(models.Model): diff --git a/tjdests/apps/destinations/views.py b/tjdests/apps/destinations/views.py index 4cf6362..ead33dc 100644 --- a/tjdests/apps/destinations/views.py +++ b/tjdests/apps/destinations/views.py @@ -7,7 +7,9 @@ from ..authentication.models import User from .models import College, Decision -class StudentDestinationListView(LoginRequiredMixin, UserPassesTestMixin, ListView): +class StudentDestinationListView( + LoginRequiredMixin, UserPassesTestMixin, ListView +): # pylint: disable=too-many-ancestors model = User paginate_by = 20 @@ -23,9 +25,10 @@ class StudentDestinationListView(LoginRequiredMixin, UserPassesTestMixin, ListVi return queryset - def get_context_data(self, *, object_list=None, **kwargs): - context = super(StudentDestinationListView, - self).get_context_data(**kwargs) + def get_context_data( + self, *, object_list=None, **kwargs + ): # pylint: disable=unused-argument + context = super().get_context_data(**kwargs) college_id = self.request.GET.get("college", None) if college_id is not None: @@ -39,7 +42,9 @@ class StudentDestinationListView(LoginRequiredMixin, UserPassesTestMixin, ListVi template_name = "destinations/student_list.html" -class CollegeDestinationListView(LoginRequiredMixin, UserPassesTestMixin, ListView): +class CollegeDestinationListView( + LoginRequiredMixin, UserPassesTestMixin, ListView +): # pylint: disable=too-many-ancestors model = College paginate_by = 20 queryset = ( diff --git a/tjdests/apps/profile/forms.py b/tjdests/apps/profile/forms.py index 774990b..03b933c 100644 --- a/tjdests/apps/profile/forms.py +++ b/tjdests/apps/profile/forms.py @@ -1,5 +1,6 @@ from crispy_forms.helper import FormHelper from crispy_forms.layout import Submit + from django import forms from tjdests.apps.authentication.models import User diff --git a/tjdests/apps/profile/views.py b/tjdests/apps/profile/views.py index 852d006..48461d3 100644 --- a/tjdests/apps/profile/views.py +++ b/tjdests/apps/profile/views.py @@ -10,14 +10,18 @@ from django.views.generic import CreateView, DeleteView, UpdateView from tjdests.apps.authentication.decorators import require_accept_tos from tjdests.apps.destinations.models import Decision, TestScore -from .forms import DecisionForm, ProfilePublishForm +from ..authentication.models import User +from .forms import ProfilePublishForm @login_required @require_accept_tos def profile_view(request: HttpRequest): - test_scores = TestScore.objects.filter(user=request.user) - decisions = Decision.objects.filter(user=request.user) + assert request.user is User + + # mypy is bad. + test_scores = TestScore.objects.filter(user=request.user) # type: ignore + decisions = Decision.objects.filter(user=request.user) # type: ignore # A POST request would mean that the user is saving their profile publication status if request.method == "POST": diff --git a/tjdests/settings/__init__.py b/tjdests/settings/__init__.py index 1820fe9..7d99eaf 100644 --- a/tjdests/settings/__init__.py +++ b/tjdests/settings/__init__.py @@ -12,8 +12,9 @@ https://docs.djangoproject.com/en/3.2/ref/settings/ import logging import os from pathlib import Path - # Build paths inside the project like this: BASE_DIR / 'subdir'. +from typing import List + BASE_DIR = Path(__file__).resolve().parent.parent @@ -26,7 +27,7 @@ SECRET_KEY = "django-insecure-7nju0o%j&gz7&v^05iuq*tn$_iwvtjh1cq26@is(u2d4snkum5 # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = [] +ALLOWED_HOSTS: List[str] = [] # Application definition @@ -157,7 +158,6 @@ CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" CRISPY_TEMPLATE_PACK = "bootstrap5" try: - from .secret import * + from .secret import * # noqa # pylint: disable=unused-import except ImportError: logging.warning("Error importing secret.py") - pass diff --git a/tjdests/settings/secret.sample.py b/tjdests/settings/secret.sample.py index e428b95..8ef7a23 100644 --- a/tjdests/settings/secret.sample.py +++ b/tjdests/settings/secret.sample.py @@ -1,5 +1,7 @@ # Deployed senior graduation year # e.g. if deploying in spring 2021, then 2021 +from typing import List + SENIOR_GRAD_YEAR = 2021 # Branding name @@ -7,7 +9,7 @@ BRANDING_NAME = "TJ Destinations" # DEBUG and authorized hosts DEBUG = True -ALLOWED_HOSTS = [] +ALLOWED_HOSTS: List[str] = [] # secret SECRET_KEY = "supersecret" @@ -17,5 +19,5 @@ SOCIAL_AUTH_ION_KEY = "ionkey" SOCIAL_AUTH_ION_SECRET = "ionsecret" # Message blast - treated as HTML safe text -# type: str +# type is str GLOBAL_MESSAGE = None