Skip to content

Commit

Permalink
Merge branch 'main' into feature/add-expo-build-workflow
Browse files Browse the repository at this point in the history
  • Loading branch information
whusterj authored Sep 16, 2024
2 parents 36f03bd + a16b513 commit dd1b9c5
Show file tree
Hide file tree
Showing 19 changed files with 274 additions and 168 deletions.
1 change: 1 addition & 0 deletions .github/workflows/mobile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ jobs:
publish:
if: needs.configuremobile.outputs.BUILD_MOBILE_APP != 0
needs: configuremobile
runs-on: ubuntu-latest
steps:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,4 @@ jobs:
mkdir -p client/dist/static
pipenv run python server/manage.py collectstatic
pipenv run pytest --mccabe --cov=my_project -vv server/my_project
pipenv run coverage report --fail-under=20
pipenv run coverage report --fail-under=60
1 change: 0 additions & 1 deletion Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ pytest-instafail = "==0.4.2"
PyYAML = "==6.0.1"
PyGithub = "==1.55"
Jinja2 = "==3.0.1"
jinja2-time = "*"

[dev-packages]

Expand Down
182 changes: 98 additions & 84 deletions Pipfile.lock

Large diffs are not rendered by default.

64 changes: 30 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,45 +6,41 @@ A production-ready Django SPA app on Heroku in 20-min or less!

## Quick Start

First, get cookiecutter, as detailed in the [official documentation](https://cookiecutter.readthedocs.io/en/stable/installation.html#install-cookiecutter).
First, get `pipx` for your system, if you don't already have it [installing pipx](https://pipx.pypa.io/stable/installation/#installing-pipx).


You will also need some of the libraries being used to generate your artifacts

`python -m pip install Jinja2 jinja2-time`

Now run it against this repo:
Adn run the following command:

```bash
cookiecutter git@github.com:thinknimble/tn-spa-bootstrapper.git
pipx install cookiecutter
pipx run cookiecutter gh:thinknimble/tn-spa-bootstrapper
```

## Features

See: [Maintained Foundation fork]

- For Django 3.1
- Uses Python 3.10 by default
- Renders Django projects with 100% starting test coverage
- Secure by default. We believe in SSL.
- Optimized development and production settings
- Comes with custom user model ready to go
- Optional basic ASGI setup for Websockets
- Optional basic Django channel setup for Websockets
- Optional client side applications Vue or React
- Send emails using [Mailgun] by default or Amazon SES if AWS is selected cloud provider.
- Media storage using Amazon S3 or Google Cloud Storage
- [Procfile] for deploying to Heroku
- Run tests with unittest or pytest
- Default integration with [pre-commit] for identifying simple issues before submission to code review
- Integration with [Rollbar] for error logging
- For Django 3.1
- Uses Python 3.10 by default
- Renders Django projects with 100% starting test coverage
- Secure by default. We believe in SSL.
- Optimized development and production settings
- Comes with custom user model ready to go
- Optional basic ASGI setup for Websockets
- Optional basic Django channel setup for Websockets
- Optional client side applications Vue or React
- Send emails using [Mailgun] by default or Amazon SES if AWS is selected cloud provider.
- Media storage using Amazon S3 or Google Cloud Storage
- [Procfile] for deploying to Heroku
- Run tests with unittest or pytest
- Default integration with [pre-commit] for identifying simple issues before submission to code review
- Integration with [Rollbar] for error logging

## Optional Integrations

These features can be enabled after initial project setup:

- Serve static files from Amazon S3 or Whitenoise
- Integration with [MailHog] for local email testing
- Serve static files from Amazon S3 or Whitenoise
- Integration with [MailHog] for local email testing

## Usage

Expand All @@ -71,22 +67,22 @@ Answer the prompts with your own desired options. For example:
3 - None
Choose from 1, 2, 3 [1]: 1
Error: "my_project" directory already exists
william@Williams-MacBook-Pro thinknimble % rm -rf my_project
william@Williams-MacBook-Pro thinknimble % rm -rf my_project
william@Williams-MacBook-Pro thinknimble % cookiecutter git@github.com:thinknimble/tn-spa-cookiecutter.git --checkout cleanup
You've downloaded /Users/william/.cookiecutters/tn-spa-cookiecutter before. Is it okay to delete and re-download it? [yes]:
project_name [My Project]:
author_name [ThinkNimble]:
email [hello@thinknimble.com]:
project_slug [my_project]:
You've downloaded /Users/william/.cookiecutters/tn-spa-cookiecutter before. Is it okay to delete and re-download it? [yes]:
project_name [My Project]:
author_name [ThinkNimble]:
email [hello@thinknimble.com]:
project_slug [my_project]:
Select mail_service:
1 - Mailgun
2 - Amazon SES
3 - Custom SMTP
Choose from 1, 2, 3 [1]:
Choose from 1, 2, 3 [1]:
Select client_app:
1 - Vue3
2 - None
Choose from 1, 2 [1]:
Choose from 1, 2 [1]:

Create a git repo and push it there::

Expand All @@ -95,7 +91,7 @@ git init
git add .
git commit -m "first awesome commit"
git remote set-url origin git@github.com:thinknimble/the-rock.git
git push -u origin main
git push -u origin main
```

Now take a look at your repo. Don't forget to carefully look at the generated README. Awesome, right?
Expand Down
1 change: 0 additions & 1 deletion cookiecutter.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"email": "hello@thinknimble.com",
"project_slug": "{{ cookiecutter.project_name.lower()|replace(' ', '_')|replace('-', '_')|replace('.', '_')|trim() }}",
"_extensions": [
"jinja2_time.TimeExtension",
"cookiecutter.extensions.RandomStringExtension"
],
"_copy_without_render": [
Expand Down
11 changes: 11 additions & 0 deletions hooks/post_gen_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ def remove_mobile_client_files(client):
rmtree(join(mobile_clients_path, client))


def remove_special_mobile_files():
file_names = [join("scripts/setup_mobile_config.sh")]
directories = [join("resources")]
for file in file_names:
if exists(file):
remove(file)
for directory in directories:
rmtree(directory)


def move_mobile_client_to_root(client):
if exists("mobile"):
rmtree("mobile")
Expand Down Expand Up @@ -165,6 +175,7 @@ def main():
move_mobile_client_to_root("react-native")
else:
remove_expo_yaml_files()
remove_special_mobile_files()

clean_up_clients_folder()

Expand Down
2 changes: 1 addition & 1 deletion {{cookiecutter.project_slug}}/pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
DJANGO_SETTINGS_MODULE = {{ cookiecutter.project_slug }}.test_settings
python_files = tests.py test_*.py *_tests.py
addopts = --strict-markers --no-migrations
mccabe-complexity=10
mccabe-complexity=8
filterwarnings =
ignore::DeprecationWarning

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@ class Meta:
abstract = True

def __str__(self):
return "ah yes"
return "__str__ not defined for this model"
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class CustomUserAdmin(UserAdmin):
)
},
),
("Admin Options", {"classes": ("collapse",), "fields": ("is_staff", "groups")}),
("Admin Options", {"classes": ("collapse",), "fields": ("is_active", "is_staff", "is_superuser", "groups")}),
)
add_fieldsets = (
(
Expand All @@ -35,7 +35,15 @@ class CustomUserAdmin(UserAdmin):
},
),
)
list_display = ("email", "first_name", "last_name", "is_active", "is_staff", "is_superuser", "permissions")
list_display = (
"email",
"permissions",
"is_active",
"is_staff",
"is_superuser",
"first_name",
"last_name",
)
list_display_links = (
"is_active",
"email",
Expand All @@ -57,7 +65,7 @@ class CustomUserAdmin(UserAdmin):
ordering = []

def permissions(self, obj):
return ", ".join([g.name for g in obj.groups.all()])
return [g.name for g in obj.groups.all()]

class Media(AutocompleteAdminMedia):
pass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
class UserFactory(factory.Factory):
email = factory.faker.Faker("email")
password = factory.PostGenerationMethodCall("set_password", "password")
first_name = factory.faker.Faker("first_name")
last_name = factory.faker.Faker("last_name")

class Meta:
model = User
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,26 @@
logger = logging.getLogger(__name__)


class UserQuerySet(models.QuerySet):
def for_user(self, user):
if not user or user.is_anonymous:
return self.none()
elif user.is_staff:
return self.all()
return self.filter(pk=user.pk)


class UserManager(BaseUserManager):
"""Custom User model manager, eliminating the 'username' field."""

use_in_migrations = True

def get_queryset(self):
return UserQuerySet(self.model, using=self.db)

def for_user(self, user):
return self.get_queryset().for_user(user)

def _create_user(self, email, password, **extra_fields):
"""
Create and save a User with the given email and password.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from rest_framework import permissions


class CreateOnlyPermissions(permissions.BasePermission):
def has_permission(self, request, view):
if view.action == "create":
return True
return False
class HasUserPermissions(permissions.BasePermission):
"""Admins should be able to perform any action, regular users should be able to edit and delete self."""

def has_object_permission(self, request, view, obj):
return request.user.is_authenticated and (request.user.is_staff or obj == request.user)
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class Meta:
"last_name",
"full_name",
)
read_only_fields = ["email"]


class UserLoginSerializer(serializers.ModelSerializer):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.contrib.auth import authenticate
from django.test import override_settings
from django.test.client import RequestFactory
from rest_framework import status
from rest_framework.response import Response

from .models import User
Expand Down Expand Up @@ -34,6 +35,13 @@ def test_create_user():
assert not user.is_superuser


@pytest.mark.django_db
def test_create_user_api(api_client):
data = {"email": "example@example.com", "password": "password", "first_name": "Test", "last_name": "User"}
res = api_client.post("/api/users/", data, format="json")
assert res.status_code == status.HTTP_201_CREATED, res.data


@pytest.mark.django_db
def test_create_superuser():
superuser = User.objects.create_superuser(email="test@example.com", password="password", first_name="Leslie", last_name="Burke")
Expand All @@ -50,7 +58,59 @@ def test_create_user_from_factory(sample_user):
@pytest.mark.django_db
def test_user_can_login(api_client, sample_user):
res = api_client.post("/api/login/", {"email": sample_user.email, "password": "password"}, format="json")
assert res.status_code == 200
assert res.status_code == status.HTTP_200_OK


@pytest.mark.django_db
def test_wrong_email(api_client, sample_user):
res = api_client.post("/api/login/", {"email": "wrong@example.com", "password": "password"}, format="json")
assert res.status_code == status.HTTP_400_BAD_REQUEST


@pytest.mark.django_db
def test_wrong_password(api_client, sample_user):
res = api_client.post("/api/login/", {"email": sample_user.email, "password": "wrong"}, format="json")
assert res.status_code == status.HTTP_400_BAD_REQUEST


@pytest.mark.django_db
def test_get_user(api_client, sample_user):
api_client.force_authenticate(sample_user)
res = api_client.get(f"/api/users/{sample_user.pk}/")
assert res.status_code == status.HTTP_200_OK
assert res.data["email"] == sample_user.email


@pytest.mark.django_db
def test_get_other_user(api_client, sample_user, user_factory):
api_client.force_authenticate(sample_user)
other_user = user_factory()
other_user.save()
res = api_client.get(f"/api/users/{other_user.pk}/")
assert res.status_code == status.HTTP_404_NOT_FOUND


@pytest.mark.django_db
def test_update_user(api_client, sample_user):
existing_email = sample_user.email
api_client.force_authenticate(sample_user)
data = {"email": "example@example.com", "password": "password", "first_name": "Test", "last_name": "User"}
res = api_client.put(f"/api/users/{sample_user.pk}/", data, format="json")
assert res.status_code == status.HTTP_200_OK
sample_user.refresh_from_db()
# Email should NOT have changed
assert sample_user.email == existing_email
assert sample_user.first_name == data["first_name"] == res.data["first_name"]
assert sample_user.last_name == data["last_name"] == res.data["last_name"]


@pytest.mark.django_db
def test_delete_user(api_client, sample_user):
api_client.force_authenticate(sample_user)
res = api_client.delete(f"/api/users/{sample_user.pk}/")
assert res.status_code == status.HTTP_204_NO_CONTENT
sample_user.refresh_from_db()
assert sample_user.is_active is False


@pytest.mark.use_requests
Expand All @@ -67,7 +127,7 @@ def test_password_reset(caplog, api_client, sample_user):

# Verify the link works for reseting the password
response = api_client.post(password_reset_url, data={"password": "new_password"}, format="json")
assert response.status_code == 200
assert response.status_code == status.HTTP_200_OK

# New Password should now work for authentication
serializer = UserLoginSerializer(data={"email": sample_user.email, "password": "new_password"})
Expand All @@ -87,7 +147,7 @@ class TestPreviewTemplateView:
@override_settings(DEBUG=False)
def test_disabled_if_not_debug(self, client):
response = client.post(self.url)
assert response.status_code == 404
assert response.status_code == status.HTTP_404_NOT_FOUND

@override_settings(DEBUG=True)
def test_enabled_if_debug(self, client):
Expand All @@ -98,19 +158,19 @@ def test_enabled_if_debug(self, client):
@override_settings(DEBUG=True)
def test_no_template_provided(self, client):
response = client.post(self.url, data={"_send_to": "someone@example.com"})
assert response.status_code == 400
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert any("You must provide a template name" in e for e in response.json())

@override_settings(DEBUG=True)
def test_invalid_template_provided(self, client):
response = client.post(f"{self.url}?template=SOME_TEMPLATE/WHICH_DOES_NOT/EXIST", data={"_send_to": "someone@example.com"})
assert response.status_code == 400
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert any("Invalid template name" in e for e in response.json())

@override_settings(DEBUG=True)
def test_missing_send_to(self, client):
response = client.post(f"{self.url}?template=SOME_TEMPLATE/WHICH_DOES_NOT/EXIST")
assert response.status_code == 400
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "This field is required." in response.json()["_send_to"]

def test_parse_value_without_model(self):
Expand Down
Loading

0 comments on commit dd1b9c5

Please sign in to comment.