diff --git a/README.md b/README.md index 35eadc7..07aa208 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ + + + + Cookiecutter Django-Vue ======================= @@ -8,17 +12,16 @@ inspired by [Cookiecutter Django](https://github.com/pydanny/cookiecutter-django

- Features -------- - [Docker](https://www.docker.com/) - [12 Factor](http://12factor.net/) - -- Frontend: [Vue](https://vuejs.org/) + vue-cli +- Server: [Nginx](https://nginx.org/) +- Frontend: [Vue](https://vuejs.org/) + [vue-cli](https://cli.vuejs.org/) - Backend: [Django](https://www.djangoproject.com/) - Database: [PostgreSQL](https://www.postgresql.org/) - +- API: REST or GraphQL Optional Integrations --------------------- @@ -27,7 +30,9 @@ Optional Integrations - Integration with [MailHog](https://github.com/mailhog/MailHog) for local email testing - Integration with [Sentry](https://sentry.io/welcome/) for frontend and backend errors logging +- Integration with [Portainer](https://portainer.io/) (management UI for docker) - Integration with [Google Analytics](https://www.google.com/analytics/) or [Yandex Metrika](https://tech.yandex.ru/metrika/) for web-analytics +- Automatic database backups Usage ----- @@ -45,24 +50,33 @@ will be created for you. Answer the prompts with your own desired options. For example: - ======================= GENERAL ====================== [ ]: + ======================== INFO ======================= [ ]: project_name [Project Name]: Website project_slug [website]: website description [Short description]: My awesome website author [Your Name]: Your Name email []: - ======================= DEVOPS ======================= [ ]: + ====================== GENERAL ====================== [ ]: + Select api: + 1 - REST + 2 - GraphQL + Choose from 1, 2 [1]: 2 + backups [y]: y + ==================== INTEGRATIONS =================== [ ]: use_sentry [y]: y - ======================= BACKEND ====================== [ ]: + use_portainer [y]: y use_mailhog [y]: y - custom_user [n]: n - ======================= FRONTEND ===================== [ ]: Select analytics: 1 - Google Analytics 2 - Yandex Metrika 3 - None Choose from 1, 2, 3 [1]: 2 +Project creation will cause some odd newlines and linter errors, so I'd recommend: + + $ autopep8 -r --in-place --aggressive --aggressive . + $ npm run lint --fix + Now you can start project with [docker-compose](https://docs.docker.com/compose/): @@ -72,3 +86,9 @@ For production you'll need to fill out `.env` file and use `docker-compose-prod.yml` file: $ docker-compose -f docker-compose-prod.yml up --build -d + + +Contributing +------------ + +Help and feedback are welcome :) \ No newline at end of file diff --git a/cookiecutter.json b/cookiecutter.json index 7cc4ef9..f73254d 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -1,5 +1,5 @@ { - "======================= GENERAL ======================": " ", + "======================== INFO =======================": " ", "project_name": "Project Name", "project_slug": "{{ cookiecutter.project_name.lower()|replace(' ', '_')|replace('-', '_') }}", "domain": "{{ cookiecutter.project_slug }}.com", @@ -8,14 +8,13 @@ "author": "Your Name", "email": "admin@{{ cookiecutter.domain }}", - "======================= DEVOPS =======================": " ", - "use_travis": "y", + "====================== GENERAL ======================": " ", + "api": ["REST", "GraphQL"], + "backups": "y", + + "==================== INTEGRATIONS ===================": " ", "use_sentry": "y", - - "======================= BACKEND ======================": " ", + "use_portainer": "y", "use_mailhog": "y", - "custom_user": "n", - - "======================= FRONTEND =====================": " ", - "analytics": ["Google Analytics", "Yandex Metrika", "None"], + "analytics": ["Google Analytics", "Yandex Metrika", "None"] } diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 262f0b0..fcc9c03 100644 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -1,7 +1,7 @@ """ 1. Generates and saves random secret key 2. Renames env.example to .env -3. Removes users app if it isn't going to be used +3. Deletes unused API files """ import os import random @@ -12,9 +12,9 @@ PROJECT_DIRECTORY = os.path.realpath(os.path.curdir) -def set_secret_key(file_location): +def set_secret_key(): """ Generates and saves random secret key """ - with open(file_location) as f: + with open(os.path.join(PROJECT_DIRECTORY, 'env.example')) as f: file_ = f.read() punctuation = string.punctuation.replace('"', '').replace("'", '').replace('\\', '') @@ -22,7 +22,7 @@ def set_secret_key(file_location): file_ = file_.replace('CHANGEME!!!', secret, 1) # Write the results - with open(file_location, 'w') as f: + with open(os.path.join(PROJECT_DIRECTORY, 'env.example'), 'w') as f: f.write(file_) @@ -30,20 +30,29 @@ def rename_env_file(): """ Renames env file """ os.rename(os.path.join(PROJECT_DIRECTORY, 'env.example'), os.path.join(PROJECT_DIRECTORY, '.env')) -def remove_users_app(): - """ Removes users app if it isn't going to be used """ - users_app = os.path.join(PROJECT_DIRECTORY, '{{ cookiecutter.project_slug }}/users') - shutil.rmtree(users_app) - for filename in ['modules/auth.js', 'services/users.js']: - os.remove(os.path.join(PROJECT_DIRECTORY, '{{ cookiecutter.project_slug }}/static/store/' + filename)) - -# Removes users app if it isn't going to be used -if '{{ cookiecutter.custom_user }}' == 'n': - remove_users_app() - -# Generates and saves random secret key -set_secret_key(os.path.join(PROJECT_DIRECTORY, 'env.example')) # env file - -# Renames env file +def delete_api_files(): + """ Deletes unused API files """ + if '{{ cookiecutter.api }}' == 'REST': + files = [ + '.graphqlrc', + 'backend/config/schema.py', + 'backend/apps/users/schema.py', + 'frontend/src/apollo.js', + ] + shutil.rmtree(os.path.join(PROJECT_DIRECTORY, 'frontend/src/graphql')) + else: + files = [ + 'backend/config/api.py', + 'backend/apps/users/views.py', + 'backend/apps/users/serializers.py', + ] + shutil.rmtree(os.path.join(PROJECT_DIRECTORY, 'frontend/src/store')) + + for filename in files: + os.remove(os.path.join(PROJECT_DIRECTORY, filename)) + + +set_secret_key() rename_env_file() +delete_api_files() \ No newline at end of file diff --git a/tests/test_docker.sh b/tests/test_docker.sh index 4e41686..ed8c24c 100644 --- a/tests/test_docker.sh +++ b/tests/test_docker.sh @@ -1,8 +1,7 @@ #!/bin/sh # install test requirements -pip install pipenv -pipenv install --system +pip install -r requirements.txt # create a cache directory mkdir -p .cache/docker && cd .cache/docker @@ -11,5 +10,4 @@ mkdir -p .cache/docker && cd .cache/docker # DEFAULT SETTINGS cookiecutter ../../ --no-input --overwrite-if-exists && cd project_name -# run the project's tests -docker-compose run backend py.test +docker-compose run backend python manage.py check diff --git a/{{cookiecutter.project_slug}}/.editorconfig b/{{cookiecutter.project_slug}}/.editorconfig new file mode 100644 index 0000000..3876b05 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +indent_size = 2 +charset = utf-8 +end_of_line = lf +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_size = 4 +max_line_length = 120 diff --git a/{{cookiecutter.project_slug}}/.gitignore b/{{cookiecutter.project_slug}}/.gitignore new file mode 100644 index 0000000..c14f408 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.gitignore @@ -0,0 +1,78 @@ +### OSX ### +.DS_Store +.AppleDouble +.LSOverride + +### SublimeText ### +# cache files for sublime text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +*.sublime-project +*.sublime-workspace + +# sftp configuration file +sftp-config.json + +# Basics +*.py[cod] +__pycache__ + +# Logs +logs +*.log +pip-log.txt +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Unit test / coverage reports +.coverage +.tox +nosetests.xml +htmlcov + +# Translations +*.mo +*.pot + +# Webpack +webpack-stats.json +dist/ + +# Vim +*~ +*.swp +*.swo + +# npm +node_modules + +# Compass +.sass-cache + +# User-uploaded media +media/ + +# Collected staticfiles +*/staticfiles/ + +.cache/ +**/certs + +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw* + +# VS code +.vscode +.pythonconfig + +# Venv +venv diff --git a/{{cookiecutter.project_slug}}/.graphqlrc b/{{cookiecutter.project_slug}}/.graphqlrc new file mode 100644 index 0000000..cdc6b31 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.graphqlrc @@ -0,0 +1,5 @@ +{ + "request": { + "url": "http://localhost:8000/graphql" + } +} diff --git a/{{cookiecutter.project_slug}}/README.md b/{{cookiecutter.project_slug}}/README.md index b98e387..76f70ab 100644 --- a/{{cookiecutter.project_slug}}/README.md +++ b/{{cookiecutter.project_slug}}/README.md @@ -3,8 +3,10 @@ {{cookiecutter.description}} - - - + + + - {% endif %} + +## Development ++ run `docker-compose up --build` diff --git a/{{cookiecutter.project_slug}}/backend/Dockerfile b/{{cookiecutter.project_slug}}/backend/Dockerfile new file mode 100644 index 0000000..da07082 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.6 + +# python envs +ENV PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=off \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=100 + +# python dependencies +COPY ./requirements.txt / +RUN pip install -r ./requirements.txt + +# upload scripts +COPY ./scripts/entrypoint.sh ./scripts/start.sh ./scripts/gunicorn.sh / + +WORKDIR /app diff --git a/{{cookiecutter.project_slug}}/backend/__init__.py b/{{cookiecutter.project_slug}}/backend/__init__.py index 76f97b1..e69de29 100644 --- a/{{cookiecutter.project_slug}}/backend/__init__.py +++ b/{{cookiecutter.project_slug}}/backend/__init__.py @@ -1,2 +0,0 @@ -__version__ = '0.1.0' -__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')]) diff --git a/{{cookiecutter.project_slug}}/backend/config/settings/__init__.py b/{{cookiecutter.project_slug}}/backend/apps/__init__.py similarity index 100% rename from {{cookiecutter.project_slug}}/backend/config/settings/__init__.py rename to {{cookiecutter.project_slug}}/backend/apps/__init__.py diff --git a/{{cookiecutter.project_slug}}/backend/users/__init__.py b/{{cookiecutter.project_slug}}/backend/apps/users/__init__.py similarity index 100% rename from {{cookiecutter.project_slug}}/backend/users/__init__.py rename to {{cookiecutter.project_slug}}/backend/apps/users/__init__.py diff --git a/{{cookiecutter.project_slug}}/backend/users/admin.py b/{{cookiecutter.project_slug}}/backend/apps/users/admin.py similarity index 90% rename from {{cookiecutter.project_slug}}/backend/users/admin.py rename to {{cookiecutter.project_slug}}/backend/apps/users/admin.py index 3a9c7db..7850a9d 100644 --- a/{{cookiecutter.project_slug}}/backend/users/admin.py +++ b/{{cookiecutter.project_slug}}/backend/apps/users/admin.py @@ -2,8 +2,8 @@ from django.contrib.auth.models import Group from django.contrib.auth.admin import UserAdmin as BaseUserAdmin -from .models import User -from .forms import UserChangeForm, UserCreationForm +from apps.users.models import User +from apps.users.forms import UserChangeForm, UserCreationForm class UserAdmin(BaseUserAdmin): @@ -18,13 +18,13 @@ class UserAdmin(BaseUserAdmin): ['Auth', {'fields': ['email', 'password']}], ['Personal info', {'fields': ['last_name', 'first_name', 'avatar']}], ['Settings', {'fields': ['groups', 'is_admin', 'is_active', 'is_staff', 'is_superuser']}], - ['Important dates', {'fields': ['last_login', 'registered_at']}] + ['Important dates', {'fields': ['last_login', 'registered_at']}], ] # add_fieldsets is not a standard ModelAdmin attribute. UserAdmin # overrides get_fieldsets to use this attribute when creating a user. add_fieldsets = [ [None, {'classes': ['wide'], - 'fields': ['email', 'first_name', 'last_name', 'password1', 'password2']}] + 'fields': ['email', 'first_name', 'last_name', 'password1', 'password2']}], ] search_fields = ['email'] ordering = ['email'] diff --git a/{{cookiecutter.project_slug}}/backend/users/apps.py b/{{cookiecutter.project_slug}}/backend/apps/users/apps.py similarity index 65% rename from {{cookiecutter.project_slug}}/backend/users/apps.py rename to {{cookiecutter.project_slug}}/backend/apps/users/apps.py index 2389055..22fd981 100644 --- a/{{cookiecutter.project_slug}}/backend/users/apps.py +++ b/{{cookiecutter.project_slug}}/backend/apps/users/apps.py @@ -2,5 +2,5 @@ class UsersConfig(AppConfig): - name = '{{cookiecutter.project_slug}}.users' + name = 'apps.users' verbose_name = 'Users' diff --git a/{{cookiecutter.project_slug}}/backend/users/forms.py b/{{cookiecutter.project_slug}}/backend/apps/users/forms.py similarity index 91% rename from {{cookiecutter.project_slug}}/backend/users/forms.py rename to {{cookiecutter.project_slug}}/backend/apps/users/forms.py index 3b9feee..ca6028d 100644 --- a/{{cookiecutter.project_slug}}/backend/users/forms.py +++ b/{{cookiecutter.project_slug}}/backend/apps/users/forms.py @@ -1,14 +1,14 @@ from django import forms from django.contrib.auth.forms import ReadOnlyPasswordHashField -from .models import User +from apps.users.models import User class UserCreationForm(forms.ModelForm): - ''' + """ A form for creating new users. Includes all the required fields, plus a repeated password - ''' + """ password1 = forms.CharField(label='Password', widget=forms.PasswordInput) password2 = forms.CharField(label='Password confirmation', widget=forms.PasswordInput) @@ -34,13 +34,13 @@ def save(self, commit=True): class UserChangeForm(forms.ModelForm): - ''' + """ A form for updating users. Includes all the fields on the user, but replaces the password field with admin's password hash display field. - ''' - help_text = '''Raw passwords are not stored, so there is no way to see this user's password, - but you can change the password using this form.''' + """ + help_text = """Raw passwords are not stored, so there is no way to see this user's password, + but you can change the password using this form.""" password = ReadOnlyPasswordHashField(label='Password', help_text=help_text) class Meta: diff --git a/{{cookiecutter.project_slug}}/backend/users/migrations/0001_initial.py b/{{cookiecutter.project_slug}}/backend/apps/users/migrations/0001_initial.py similarity index 100% rename from {{cookiecutter.project_slug}}/backend/users/migrations/0001_initial.py rename to {{cookiecutter.project_slug}}/backend/apps/users/migrations/0001_initial.py diff --git a/{{cookiecutter.project_slug}}/backend/users/migrations/__init__.py b/{{cookiecutter.project_slug}}/backend/apps/users/migrations/__init__.py similarity index 100% rename from {{cookiecutter.project_slug}}/backend/users/migrations/__init__.py rename to {{cookiecutter.project_slug}}/backend/apps/users/migrations/__init__.py diff --git a/{{cookiecutter.project_slug}}/backend/users/models.py b/{{cookiecutter.project_slug}}/backend/apps/users/models.py similarity index 100% rename from {{cookiecutter.project_slug}}/backend/users/models.py rename to {{cookiecutter.project_slug}}/backend/apps/users/models.py diff --git a/{{cookiecutter.project_slug}}/backend/apps/users/schema.py b/{{cookiecutter.project_slug}}/backend/apps/users/schema.py new file mode 100644 index 0000000..78089b6 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/apps/users/schema.py @@ -0,0 +1,139 @@ +import graphene +import graphql_jwt +from graphene_django import DjangoObjectType +from uuid import uuid4 + +from django.contrib.auth import logout +from django.conf import settings +from django.core.mail import send_mail +from django.template.loader import render_to_string + +from apps.users.models import User + + +class UserType(DjangoObjectType): + """ User type object """ + + class Meta: + model = User + only_fields = [ + 'id', + 'email', + 'first_name', + 'last_name', + 'registered_at', + ] + + +class Query(object): + user = graphene.Field(UserType, id=graphene.Int(required=True)) + users = graphene.List(UserType) + profile = graphene.Field(UserType) + + @staticmethod + def resolve_user(cls, info, **kwargs): + return User.objects.get(id=kwargs.get('id')) + + @staticmethod + def resolve_users(cls, info, **kwargs): + return User.objects.all() + + @staticmethod + def resolve_profile(cls, info, **kwargs): + if info.context.user.is_authenticated: + return info.context.user + + +class Register(graphene.Mutation): + """ Mutation to register a user """ + success = graphene.Boolean() + errors = graphene.List(graphene.String) + + class Arguments: + email = graphene.String(required=True) + password = graphene.String(required=True) + first_name = graphene.String(required=True) + last_name = graphene.String(required=True) + + def mutate(self, info, email, password, first_name, last_name): + if User.objects.filter(email__iexact=email).exists(): + errors = ['emailAlreadyExists'] + return Register(success=False, errors=errors) + + # create user + user = User.objects.create( + email=email, + last_name=last_name, + first_name=first_name, + ) + user.set_password(password) + user.save() + return Register(success=True) + + +class Logout(graphene.Mutation): + """ Mutation to logout a user """ + success = graphene.Boolean() + + def mutate(self, info): + logout(info.context) + return Logout(success=True) + + +class ResetPassword(graphene.Mutation): + """ Mutation for requesting a password reset email """ + success = graphene.Boolean() + + class Arguments: + email = graphene.String(required=True) + + def mutate(self, info, email): + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + errors = ['emailDoesNotExists'] + return ResetPassword(success=False, errors=errors) + + params = { + 'user': user, + 'DOMAIN': settings.DOMAIN, + } + send_mail( + subject='Password reset', + message=render_to_string('mail/password_reset.txt', params), + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[email], + ) + return ResetPassword(success=True) + + +class ResetPasswordConfirm(graphene.Mutation): + """ Mutation for requesting a password reset email """ + success = graphene.Boolean() + errors = graphene.List(graphene.String) + + class Arguments: + token = graphene.String(required=True) + password = graphene.String(required=True) + + def mutate(self, info, token, password): + try: + user = User.objects.get(token=token) + except User.DoesNotExist: + errors = ['wrongToken'] + return ResetPasswordConfirm(success=False, errors=errors) + + user.set_password(password) + user.token = uuid4() + user.save() + return ResetPasswordConfirm(success=True) + + +class Mutation(object): + login = graphql_jwt.ObtainJSONWebToken.Field() + verify_token = graphql_jwt.Verify.Field() + refresh_token = graphql_jwt.Refresh.Field() + register = Register.Field() + logout = Logout.Field() + reset_password = ResetPassword.Field() + reset_password_confirm = ResetPasswordConfirm.Field() diff --git a/{{cookiecutter.project_slug}}/backend/users/serializers.py b/{{cookiecutter.project_slug}}/backend/apps/users/serializers.py similarity index 96% rename from {{cookiecutter.project_slug}}/backend/users/serializers.py rename to {{cookiecutter.project_slug}}/backend/apps/users/serializers.py index d277cf8..caab8cc 100644 --- a/{{cookiecutter.project_slug}}/backend/users/serializers.py +++ b/{{cookiecutter.project_slug}}/backend/apps/users/serializers.py @@ -1,7 +1,8 @@ from rest_framework import serializers + from django.conf import settings -from .models import User +from apps.users.models import User class UserSerializer(serializers.ModelSerializer): diff --git a/{{cookiecutter.project_slug}}/backend/users/templates/mail/password_reset.txt b/{{cookiecutter.project_slug}}/backend/apps/users/templates/mail/password_reset.txt similarity index 100% rename from {{cookiecutter.project_slug}}/backend/users/templates/mail/password_reset.txt rename to {{cookiecutter.project_slug}}/backend/apps/users/templates/mail/password_reset.txt diff --git a/{{cookiecutter.project_slug}}/backend/users/views.py b/{{cookiecutter.project_slug}}/backend/apps/users/views.py similarity index 80% rename from {{cookiecutter.project_slug}}/backend/users/views.py rename to {{cookiecutter.project_slug}}/backend/apps/users/views.py index 238ad61..6e5984a 100644 --- a/{{cookiecutter.project_slug}}/backend/users/views.py +++ b/{{cookiecutter.project_slug}}/backend/apps/users/views.py @@ -1,99 +1,104 @@ -import requests -from uuid import uuid4 - -from django.contrib.auth import authenticate, login -from django.conf import settings -from django.core.mail import send_mail -from django.template.loader import render_to_string - -from rest_framework import viewsets, status -from rest_framework.decorators import list_route -from rest_framework.response import Response - -from .models import User -from .serializers import UserSerializer, UserWriteSerializer - - -class UserViewSet(viewsets.ModelViewSet): - queryset = User.objects.all() - serializer_class = UserSerializer - permission_classes = [] - - def get_serializer_class(self): - if self.action in ['list', 'retrieve']: - return UserSerializer - return UserWriteSerializer - - def perform_create(self, serializer): - user = serializer.save() - user.set_password(self.request.data.get('password')) - user.save() - - def perform_update(self, serializer): - user = serializer.save() - if 'password' in self.request.data: - user.set_password(self.request.data.get('password')) - user.save() - - def perform_destroy(self, instance): - instance.is_active = False - instance.save() - - @list_route(methods=['GET']) - def profile(self, request): - if request.user.is_authenticated: - serializer = self.serializer_class(request.user) - return Response(status=status.HTTP_200_OK, data=serializer.data) - return Response(status=status.HTTP_401_UNAUTHORIZED) - - @list_route(methods=['POST']) - def login(self, request, format=None): - email = request.data.get('email', None) - password = request.data.get('password', None) - user = authenticate(username=email, password=password) - - if user: - login(request, user) - return Response(status=status.HTTP_200_OK) - return Response(status=status.HTTP_404_NOT_FOUND) - - @list_route(methods=['POST']) - def register(self, request): - last_name = request.data.get('last_name', None) - first_name = request.data.get('first_name', None) - email = request.data.get('email', None) - password = request.data.get('password', None) - - if User.objects.filter(email__iexact=email).exists(): - return Response({'status': 210}) - - # user creation - user = User.objects.create(email=email, - password=password, - last_name=last_name, - first_name=first_name, - is_admin=True) - return Response(UserSerializer(user).data, status=status.HTTP_201_CREATED) - - @list_route(methods=['POST']) - def password_reset(self, request, format=None): - if User.objects.filter(email=request.data['email']).exists(): - user = User.objects.get(email=request.data['email']) - send_mail(subject='Password reset', - message=render_to_string('mail/password_reset.txt', {'user': user, 'DOMAIN': settings.DOMAIN}), - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=[request.data['email']]) - return Response(status=status.HTTP_200_OK) - else: - return Response(status=status.HTTP_404_NOT_FOUND) - - @list_route(methods=['POST']) - def password_change(self, request, format=None): - if User.objects.filter(token=request.data['token']).exists(): - user = User.objects.get(token=request.data['token']) - user.set_password(request.data['password']) - user.token = uuid4() - user.save() - return Response(status=status.HTTP_200_OK) - else: - return Response(status=status.HTTP_404_NOT_FOUND) +import requests +from uuid import uuid4 + +from django.contrib.auth import authenticate, login +from django.conf import settings +from django.core.mail import send_mail +from django.template.loader import render_to_string + +from rest_framework import viewsets, status +from rest_framework.decorators import list_route +from rest_framework.response import Response + +from apps.users.models import User +from apps.users.serializers import UserSerializer, UserWriteSerializer + + +class UserViewSet(viewsets.ModelViewSet): + queryset = User.objects.all() + serializer_class = UserSerializer + permission_classes = [] + + def get_serializer_class(self): + if self.action in ['list', 'retrieve']: + return UserSerializer + return UserWriteSerializer + + def perform_create(self, serializer): + user = serializer.save() + user.set_password(self.request.data.get('password')) + user.save() + + def perform_update(self, serializer): + user = serializer.save() + if 'password' in self.request.data: + user.set_password(self.request.data.get('password')) + user.save() + + def perform_destroy(self, instance): + instance.is_active = False + instance.save() + + @list_route(methods=['GET']) + def profile(self, request): + if request.user.is_authenticated: + serializer = self.serializer_class(request.user) + return Response(status=status.HTTP_200_OK, data=serializer.data) + return Response(status=status.HTTP_401_UNAUTHORIZED) + + @list_route(methods=['POST']) + def login(self, request, format=None): + email = request.data.get('email', None) + password = request.data.get('password', None) + user = authenticate(username=email, password=password) + + if user: + login(request, user) + return Response(status=status.HTTP_200_OK) + return Response(status=status.HTTP_404_NOT_FOUND) + + @list_route(methods=['POST']) + def register(self, request): + last_name = request.data.get('last_name', None) + first_name = request.data.get('first_name', None) + email = request.data.get('email', None) + password = request.data.get('password', None) + + if User.objects.filter(email__iexact=email).exists(): + return Response({'status': 210}) + + # user creation + user = User.objects.create( + email=email, + password=password, + last_name=last_name, + first_name=first_name, + is_admin=False, + ) + return Response(UserSerializer(user).data, status=status.HTTP_201_CREATED) + + @list_route(methods=['POST']) + def password_reset(self, request, format=None): + if User.objects.filter(email=request.data['email']).exists(): + user = User.objects.get(email=request.data['email']) + params = {'user': user, 'DOMAIN': settings.DOMAIN} + send_mail( + subject='Password reset', + message=render_to_string('mail/password_reset.txt', params), + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[request.data['email']], + ) + return Response(status=status.HTTP_200_OK) + else: + return Response(status=status.HTTP_404_NOT_FOUND) + + @list_route(methods=['POST']) + def password_change(self, request, format=None): + if User.objects.filter(token=request.data['token']).exists(): + user = User.objects.get(token=request.data['token']) + user.set_password(request.data['password']) + user.token = uuid4() + user.save() + return Response(status=status.HTTP_200_OK) + else: + return Response(status=status.HTTP_404_NOT_FOUND) diff --git a/{{cookiecutter.project_slug}}/backend/config/api.py b/{{cookiecutter.project_slug}}/backend/config/api.py index ffba87d..4e43765 100644 --- a/{{cookiecutter.project_slug}}/backend/config/api.py +++ b/{{cookiecutter.project_slug}}/backend/config/api.py @@ -1,13 +1,9 @@ from rest_framework import routers -{% if cookiecutter.custom_user == 'y' %} -from {{cookiecutter.project_slug}}.users.views import UserViewSet -{% endif %} +from apps.users.views import UserViewSet # Settings api = routers.DefaultRouter() api.trailing_slash = '/?' -{% if cookiecutter.custom_user == 'y' %} # Users API api.register(r'users', UserViewSet) -{% endif %} diff --git a/{{cookiecutter.project_slug}}/backend/config/schema.py b/{{cookiecutter.project_slug}}/backend/config/schema.py new file mode 100644 index 0000000..17b1f83 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/config/schema.py @@ -0,0 +1,15 @@ +import graphene +from graphene_django.debug import DjangoDebug + +import apps.users.schema + + +class Query(apps.users.schema.Query, graphene.ObjectType): + debug = graphene.Field(DjangoDebug, name='__debug') + + +class Mutation(apps.users.schema.Mutation, graphene.ObjectType): + ... + + +schema = graphene.Schema(query=Query, mutation=Mutation) diff --git a/{{cookiecutter.project_slug}}/backend/config/settings/base.py b/{{cookiecutter.project_slug}}/backend/config/settings.py similarity index 66% rename from {{cookiecutter.project_slug}}/backend/config/settings/base.py rename to {{cookiecutter.project_slug}}/backend/config/settings.py index 33e853f..0447fb0 100644 --- a/{{cookiecutter.project_slug}}/backend/config/settings/base.py +++ b/{{cookiecutter.project_slug}}/backend/config/settings.py @@ -1,16 +1,16 @@ -''' -Django settings for {{cookiecutter.project_name}} project. +""" +Django settings for {{ cookiecutter.project_name }} project. For more information on this file, see https://docs.djangoproject.com/en/dev/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/dev/ref/settings/ -''' +""" import environ +from datetime import timedelta -ROOT_DIR = environ.Path(__file__) - 3 # ({{cookiecutter.project_slug}}/config/settings/base.py - 3 = {{cookiecutter.project_slug}}/) -APPS_DIR = ROOT_DIR.path('{{cookiecutter.project_slug}}') +ROOT_DIR = environ.Path(__file__) - 2 # Load operating system environment variables and then prepare to use them env = environ.Env() @@ -18,27 +18,25 @@ # APP CONFIGURATION # ------------------------------------------------------------------------------ DJANGO_APPS = [ - # Default Django apps: 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - - # Admin - 'django.contrib.admin' + 'django.contrib.admin', ] THIRD_PARTY_APPS = [ + {% if cookiecutter.api == 'REST' %} 'rest_framework', - 'django_extensions' + {% elif cookiecutter.api == 'GraphQL' %} + 'graphene_django', + {% endif %} + 'django_extensions', ] -# Apps specific for this project go here. LOCAL_APPS = [ - # Your stuff: custom apps go here - {% if cookiecutter.custom_user == 'y' %} - '{{ cookiecutter.project_slug }}.users.apps.UsersConfig'{% endif %} + 'apps.users', ] # See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps @@ -52,32 +50,31 @@ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + {% if cookiecutter.api == 'GraphQL' %}'graphql_jwt.middleware.JSONWebTokenMiddleware',{% endif %} 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware' + 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] # DEBUG # ------------------------------------------------------------------------------ # See: https://docs.djangoproject.com/en/dev/ref/settings/#debug -DEBUG = False -DOMAIN = 'localhost:8000' +DEBUG = env.bool('DEBUG') +SECRET_KEY = env.str('SECRET_KEY') -# FIXTURE CONFIGURATION -# ------------------------------------------------------------------------------ -# See: https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-FIXTURE_DIRS -FIXTURE_DIRS = [ - str(APPS_DIR.path('fixtures')) -] +# DOMAINS +ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['*']) +DOMAIN = env.str('DOMAIN') # EMAIL CONFIGURATION # ------------------------------------------------------------------------------ -EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND', default='django.core.mail.backends.smtp.EmailBackend') +EMAIL_PORT = env.int('EMAIL_PORT', default='1025') +EMAIL_HOST = env.str('EMAIL_HOST', default='mailhog') # MANAGER CONFIGURATION # ------------------------------------------------------------------------------ # See: https://docs.djangoproject.com/en/dev/ref/settings/#admins ADMINS = [ - ('''{{cookiecutter.author}}''', '{{cookiecutter.email}}') + ('{{ cookiecutter.author }}', '{{ cookiecutter.email }}'), ] # See: https://docs.djangoproject.com/en/dev/ref/settings/#managers @@ -87,10 +84,15 @@ # ------------------------------------------------------------------------------ # See: https://docs.djangoproject.com/en/dev/ref/settings/#databases DATABASES = { - 'default': env.db('DATABASE_URL', default='postgres:///{{cookiecutter.project_slug}}') + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': env.str('POSTGRES_DB'), + 'USER': env.str('POSTGRES_USER'), + 'PASSWORD': env.str('POSTGRES_PASSWORD'), + 'HOST': 'postgres', + 'PORT': 5432, + }, } -DATABASES['default']['ATOMIC_REQUESTS'] = True - # GENERAL CONFIGURATION # ------------------------------------------------------------------------------ @@ -122,7 +124,7 @@ # See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS STATICFILES_DIRS = [ - str(APPS_DIR.path('static')), + str(ROOT_DIR('static')), ] # See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders @@ -152,9 +154,7 @@ TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ - str(APPS_DIR.path('static')), - ], + 'DIRS': STATICFILES_DIRS, 'OPTIONS': { 'debug': DEBUG, 'loaders': [ @@ -184,7 +184,7 @@ 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', 'django.contrib.auth.hashers.Argon2PasswordHasher', 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', - 'django.contrib.auth.hashers.BCryptPasswordHasher' + 'django.contrib.auth.hashers.BCryptPasswordHasher', ] # PASSWORD VALIDATION @@ -194,9 +194,22 @@ {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, - {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'} + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, ] +# AUTHENTICATION CONFIGURATION +# ------------------------------------------------------------------------------ +AUTHENTICATION_BACKENDS = [ + {% if cookiecutter.api == 'GraphQL' %}'graphql_jwt.backends.JSONWebTokenBackend',{% endif %} + 'django.contrib.auth.backends.ModelBackend', +] + +# Custom user app defaults +# Select the correct user model +AUTH_USER_MODEL = 'users.User' + + +{% if cookiecutter.api == 'REST' %} # DJANGO REST FRAMEWORK # ------------------------------------------------------------------------------ REST_FRAMEWORK = { @@ -213,33 +226,82 @@ 'rest_framework.parsers.FileUploadParser' ] } +{% elif cookiecutter.api == 'GraphQL' %} +# Graphene +GRAPHENE = { + 'SCHEMA': 'config.schema.schema', +} +if DEBUG: + GRAPHENE['MIDDLEWARE'] = ['graphene_django.debug.DjangoDebugMiddleware'] -# AUTHENTICATION CONFIGURATION -# ------------------------------------------------------------------------------ -AUTHENTICATION_BACKENDS = [ - 'django.contrib.auth.backends.ModelBackend' -] - -# Custom user app defaults -# Select the correct user model -{% if cookiecutter.custom_user == 'y' %} -AUTH_USER_MODEL = 'users.User' +GRAPHQL_JWT = { + 'JWT_EXPIRATION_DELTA': timedelta(days=30), + 'JWT_AUTH_HEADER': 'authorization', + 'JWT_AUTH_HEADER_PREFIX': 'Bearer', +} {% endif %} -LOGIN_REDIRECT_URL = '/login' -LOGIN_URL = '/login' - -# Location of root django.contrib.admin URL -ADMIN_URL = r'^admin/' -{% if cookiecutter.analytics == 'Google Analytics' -%} -# Google Analytics -GOOGLE_ANALYTICS = env('GOOGLE_ANALYTICS', default=None){% endif %} - -{% if cookiecutter.analytics == 'Yandex Metrika' -%} -# Yandex Metrika -YANDEX_METRIKA = env('YANDEX_METRIKA', default=1){% endif %} {% if cookiecutter.use_sentry == 'y' %} -# Raven for frontend errors logging -SENTRY_PUBLIC_DSN = env('SENTRY_PUBLIC_DSN', default=None){% endif %} +# raven sentry client +# See https://docs.sentry.io/clients/python/integrations/django/ +INSTALLED_APPS += ['raven.contrib.django.raven_compat'] +RAVEN_MIDDLEWARE = ['raven.contrib.django.raven_compat.middleware.SentryResponseErrorIdMiddleware'] +MIDDLEWARE = RAVEN_MIDDLEWARE + MIDDLEWARE + +# Sentry Configuration +SENTRY_DSN = env.str('SENTRY_DSN') +SENTRY_CLIENT = 'raven.contrib.django.raven_compat.DjangoClient' +LOGGING = { + 'version': 1, + 'disable_existing_loggers': True, + 'root': { + 'level': 'WARNING', + 'handlers': ['sentry'], + }, + 'formatters': { + 'verbose': { + 'format': '%(levelname)s %(asctime)s %(module)s ' + '%(process)d %(thread)d %(message)s' + }, + }, + 'handlers': { + 'sentry': { + 'level': 'ERROR', + 'class': 'raven.contrib.django.raven_compat.handlers.SentryHandler', + }, + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + 'formatter': 'verbose' + } + }, + 'loggers': { + 'django.db.backends': { + 'level': 'ERROR', + 'handlers': ['console'], + 'propagate': False, + }, + 'raven': { + 'level': 'DEBUG', + 'handlers': ['console'], + 'propagate': False, + }, + 'sentry.errors': { + 'level': 'DEBUG', + 'handlers': ['console'], + 'propagate': False, + }, + 'django.security.DisallowedHost': { + 'level': 'ERROR', + 'handlers': ['console', 'sentry'], + 'propagate': False, + }, + }, +} + +RAVEN_CONFIG = { + 'DSN': SENTRY_DSN +} +{% endif %} diff --git a/{{cookiecutter.project_slug}}/backend/config/settings/production.py b/{{cookiecutter.project_slug}}/backend/config/settings/production.py deleted file mode 100644 index c3400de..0000000 --- a/{{cookiecutter.project_slug}}/backend/config/settings/production.py +++ /dev/null @@ -1,130 +0,0 @@ -""" -Production Configurations - -- Use Redis for cache -{% if cookiecutter.use_sentry == 'y' -%} -- Use Sentry for error logging -{% endif %} -""" - -import logging -from datetime import datetime, timedelta - -from .base import * # noqa - -# SECRET CONFIGURATION -# ------------------------------------------------------------------------------ -# See: https://docs.djangoproject.com/en/dev/ref/settings/#secret-key -# Raises ImproperlyConfigured exception if DJANGO_SECRET_KEY not in os.environ -SECRET_KEY = env.str('DJANGO_SECRET_KEY') - -SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') - -{% if cookiecutter.use_sentry == 'y' -%} -# raven sentry client -# See https://docs.sentry.io/clients/python/integrations/django/ -INSTALLED_APPS += ['raven.contrib.django.raven_compat'] -RAVEN_MIDDLEWARE = ['raven.contrib.django.raven_compat.middleware.SentryResponseErrorIdMiddleware'] -MIDDLEWARE = RAVEN_MIDDLEWARE + MIDDLEWARE -{% endif %} - - -# SITE CONFIGURATION -ALLOWED_HOSTS = env.list('ALLOWED_HOSTS') -DOMAIN = env.str.str('DOMAIN') - -# Gunicorn -INSTALLED_APPS += ['gunicorn'] - -# TEMPLATE CONFIGURATION -# ------------------------------------------------------------------------------ -# See: -# https://docs.djangoproject.com/en/dev/ref/templates/api/#django.template.loaders.cached.Loader -TEMPLATES[0]['OPTIONS']['loaders'] = [ - ('django.template.loaders.cached.Loader', [ - 'django.template.loaders.filesystem.Loader', 'django.template.loaders.app_directories.Loader', ]), -] - -# DATABASE CONFIGURATION -# ------------------------------------------------------------------------------ - -# Use the Heroku-style specification -# Raises ImproperlyConfigured exception if DATABASE_URL not in os.environ -DATABASES['default'] = env.db('DATABASE_URL') - -# CACHING -# ------------------------------------------------------------------------------ - -REDIS_URL = env('REDIS_URL', default='redis://127.0.0.1:6379') -REDIS_LOCATION = f'{REDIS_URL}/0' -# Heroku URL does not pass the DB number, so we parse it in -CACHES = { - 'default': { - 'BACKEND': 'django_redis.cache.RedisCache', - 'LOCATION': REDIS_LOCATION, - 'OPTIONS': { - 'CLIENT_CLASS': 'django_redis.client.DefaultClient', - 'IGNORE_EXCEPTIONS': True, # mimics memcache behavior. - # http://niwinz.github.io/django-redis/latest/#_memcached_exceptions_behavior - } - } -} - -{% if cookiecutter.use_sentry == 'y' -%} -# Sentry Configuration -SENTRY_DSN = env.str('SENTRY_DSN') -SENTRY_CLIENT = env.str('DJANGO_SENTRY_CLIENT', default='raven.contrib.django.raven_compat.DjangoClient') -LOGGING = { - 'version': 1, - 'disable_existing_loggers': True, - 'root': { - 'level': 'WARNING', - 'handlers': ['sentry', ], - }, - 'formatters': { - 'verbose': { - 'format': '%(levelname)s %(asctime)s %(module)s ' - '%(process)d %(thread)d %(message)s' - }, - }, - 'handlers': { - 'sentry': { - 'level': 'ERROR', - 'class': 'raven.contrib.django.raven_compat.handlers.SentryHandler', - }, - 'console': { - 'level': 'DEBUG', - 'class': 'logging.StreamHandler', - 'formatter': 'verbose' - } - }, - 'loggers': { - 'django.db.backends': { - 'level': 'ERROR', - 'handlers': ['console', ], - 'propagate': False, - }, - 'raven': { - 'level': 'DEBUG', - 'handlers': ['console', ], - 'propagate': False, - }, - 'sentry.errors': { - 'level': 'DEBUG', - 'handlers': ['console', ], - 'propagate': False, - }, - 'django.security.DisallowedHost': { - 'level': 'ERROR', - 'handlers': ['console', 'sentry', ], - 'propagate': False, - }, - }, -} - -SENTRY_CELERY_LOGLEVEL = env.int('DJANGO_SENTRY_LOG_LEVEL', logging.INFO) -RAVEN_CONFIG = { - 'CELERY_LOGLEVEL': env.int('DJANGO_SENTRY_LOG_LEVEL', logging.INFO), - 'DSN': SENTRY_DSN -} -{% endif %} diff --git a/{{cookiecutter.project_slug}}/backend/config/urls.py b/{{cookiecutter.project_slug}}/backend/config/urls.py index dc8e872..589732b 100644 --- a/{{cookiecutter.project_slug}}/backend/config/urls.py +++ b/{{cookiecutter.project_slug}}/backend/config/urls.py @@ -1,36 +1,25 @@ -from django.conf import settings -from django.conf.urls import include, url -from django.conf.urls.static import static +from django.urls import path from django.contrib import admin -from django.views.generic import TemplateView -from django.contrib.auth.views import logout +from django.contrib.auth import logout +{% if cookiecutter.api == 'REST' %} +from django.conf.urls import include from config.api import api +{% elif cookiecutter.api == 'GraphQL' %} +from django.conf import settings +from django.views.decorators.csrf import csrf_exempt -urlpatterns = [ - url(r'^admin/', admin.site.urls), - url(r'^api/', include(api.urls)), - url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), - url(r'^logout/$', logout, {'next_page': '/'}, name='logout') -] - -if settings.DEBUG: - # Media urls for development - urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) - - -class ExtraContextTemplateView(TemplateView): - """ - Hack to pass some content to Template View - """ - def get_context_data(self, **kwargs): - context = super(ExtraContextTemplateView, self).get_context_data(**kwargs) - context['DEBUG'] = settings.DEBUG - {% if cookiecutter.use_sentry == 'y' %}context['SENTRY_PUBLIC_DSN'] = settings.SENTRY_PUBLIC_DSN{% endif %} - {% if cookiecutter.analytics == 'Yandex Metrika' -%}context['YANDEX_METRIKA'] = settings.YANDEX_METRIKA{% endif %} - {% if cookiecutter.analytics == 'Google Analytics' -%}context['GOOGLE_ANALYTICS'] = settings.GOOGLE_ANALYTICS{% endif %} - return context +from graphene_django.views import GraphQLView +{% endif %} -# App: Vue routing -urlpatterns += [url(r'^', ExtraContextTemplateView.as_view(template_name='main.html'))] +urlpatterns = [ + path('admin/', admin.site.urls, name='admin'), + path('logout/', logout, {'next_page': '/'}, name='logout'), + {% if cookiecutter.api == 'REST' %} + path('api/', include(api.urls)), + path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), + {% elif cookiecutter.api == 'GraphQL' %} + path('graphql', csrf_exempt(GraphQLView.as_view(graphiql=settings.DEBUG))), + {% endif %} +] diff --git a/{{cookiecutter.project_slug}}/backend/config/wsgi.py b/{{cookiecutter.project_slug}}/backend/config/wsgi.py index 91b384c..c0da8d8 100644 --- a/{{cookiecutter.project_slug}}/backend/config/wsgi.py +++ b/{{cookiecutter.project_slug}}/backend/config/wsgi.py @@ -1,37 +1,29 @@ """ -WSGI config for {{cookiecutter.project_name}} project. +WSGI config for serenity-escrow project. This module contains the WSGI application used by Django's development server and any production WSGI deployments. It should expose a module-level variable named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover this application via the ``WSGI_APPLICATION`` setting. - """ + import os import sys from django.core.wsgi import get_wsgi_application -# This allows easy placement of apps within the interior -# {{ cookiecutter.project_slug }} directory. -app_path = os.path.dirname(os.path.abspath(__file__)).replace('/config', '') -sys.path.append(os.path.join(app_path, '{{ cookiecutter.project_slug }}')) - -{% if cookiecutter.use_sentry == 'y' -%} -if os.environ.get('DJANGO_SETTINGS_MODULE') == 'config.settings.production': - from raven.contrib.django.raven_compat.middleware.wsgi import Sentry -{% endif %} - -# We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks -# if running multiple sites in the same mod_wsgi process. To fix this, use -# mod_wsgi daemon mode with each site in its own daemon process, or use -# os.environ["DJANGO_SETTINGS_MODULE"] = "config.settings.production" -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +# This allows easy placement of apps within the interior serenity directory. +current_path = os.path.dirname(os.path.abspath(__file__)).replace('/config', '') +sys.path.append(current_path) +sys.path.append(os.path.join(current_path, 'apps')) # This application object is used by any WSGI server configured to use this # file. This includes Django's development server, if the WSGI_APPLICATION # setting points here. application = get_wsgi_application() + {% if cookiecutter.use_sentry == 'y' -%} -if os.environ.get('DJANGO_SETTINGS_MODULE') == 'config.settings.production': - application = Sentry(application){% endif %} +from raven.contrib.django.raven_compat.middleware.wsgi import Sentry +application = Sentry(application) +{% endif %} diff --git a/{{cookiecutter.project_slug}}/backend/manage.py b/{{cookiecutter.project_slug}}/backend/manage.py old mode 100755 new mode 100644 diff --git a/{{cookiecutter.project_slug}}/backend/requirements.txt b/{{cookiecutter.project_slug}}/backend/requirements.txt new file mode 100644 index 0000000..1ac28c9 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/requirements.txt @@ -0,0 +1,53 @@ +autopep8==1.4 +attrs==18.2.0 +backcall==0.1.0 +certifi==2018.8.24 +chardet==3.0.4 +decorator==4.3.0 +Django==2.1.1 +django-environ==0.4.5 +django-extensions==2.1.2 +django-redis==4.9.0 +flake8==3.5.0 +flake8-commas==2.0.0 +flake8-mypy==17.8.0 +gevent==1.3.6 +greenlet==0.4.14 +gunicorn==19.9.0 +idna==2.7 +ipython==6.5.0 +ipython-genutils==0.2.0 +jedi==0.12.1 +mccabe==0.6.1 +mypy==0.620 +parso==0.3.1 +pexpect==4.6.0 +pickleshare==0.7.4 +Pillow==5.0.0 +prompt-toolkit==1.0.15 +psycopg2-binary==2.7.5 +ptyprocess==0.6.0 +pycodestyle==2.3.1 +pyflakes==1.6.0 +Pygments==2.2.0 +pytz==2018.5 +redis==2.10.6 +requests==2.19.1 +rope==0.11.0 +simplegeneric==0.8.1 +six==1.11.0 +traitlets==4.3.2 +typed-ast==1.1.0 +urllib3==1.23 +wcwidth==0.1.7 +Werkzeug==0.14.1 +{% if cookiecutter.use_sentry == 'y' %}raven==6.9.0{% endif %} +{% if cookiecutter.api == 'REST' %} +djangorestframework==3.8.2 +{% elif cookiecutter.api == 'GraphQL' %} +django-graphql-jwt==0.1.10 +graphene==2.1.3 +graphene-django==2.1.0 +{% endif %} + + diff --git a/{{cookiecutter.project_slug}}/backend/scripts/entrypoint.sh b/{{cookiecutter.project_slug}}/backend/scripts/entrypoint.sh new file mode 100755 index 0000000..27a0888 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/scripts/entrypoint.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -o errexit +set -o pipefail +cmd="$@" + +function postgres_ready(){ +python << END +import sys +import psycopg2 +import environ + +try: + env = environ.Env() + dbname = env.str('POSTGRES_DB') + user = env.str('POSTGRES_USER') + password = env.str('POSTGRES_PASSWORD') + conn = psycopg2.connect(dbname=dbname, user=user, password=password, host='postgres', port=5432) +except psycopg2.OperationalError: + sys.exit(-1) +sys.exit(0) +END +} + +until postgres_ready; do + >&2 echo "Postgres is unavailable - sleeping" + sleep 1 +done + +>&2 echo "Postgres is up - continuing..." +exec $cmd diff --git a/{{cookiecutter.project_slug}}/backend/scripts/gunicorn.sh b/{{cookiecutter.project_slug}}/backend/scripts/gunicorn.sh new file mode 100755 index 0000000..8ced569 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/scripts/gunicorn.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -o errexit +set -o pipefail +set -o nounset + +python manage.py migrate +python manage.py collectstatic --noinput --verbosity 0 +gunicorn config.wsgi -w 4 --worker-class gevent -b 0.0.0.0:8000 --chdir=/app diff --git a/{{cookiecutter.project_slug}}/backend/scripts/start.sh b/{{cookiecutter.project_slug}}/backend/scripts/start.sh new file mode 100755 index 0000000..743f6ca --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/scripts/start.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -o errexit +set -o pipefail +set -o nounset +set -o xtrace + +python manage.py migrate +python manage.py collectstatic --noinput --verbosity 0 +python manage.py runserver_plus 0.0.0.0:8000 diff --git a/{{cookiecutter.project_slug}}/backend/users/tests/__init__.py b/{{cookiecutter.project_slug}}/backend/static/.gitkeep similarity index 100% rename from {{cookiecutter.project_slug}}/backend/users/tests/__init__.py rename to {{cookiecutter.project_slug}}/backend/static/.gitkeep diff --git a/{{cookiecutter.project_slug}}/backend/users/tests/factories.py b/{{cookiecutter.project_slug}}/backend/users/tests/factories.py deleted file mode 100644 index 145867e..0000000 --- a/{{cookiecutter.project_slug}}/backend/users/tests/factories.py +++ /dev/null @@ -1,14 +0,0 @@ -import factory -from uuid import uuid4 - - -class UserFactory(factory.django.DjangoModelFactory): - email = factory.Sequence(lambda n: f'user-{n}@example.com') - password = factory.PostGenerationMethodCall('set_password', 'password') - first_name = 'First' - last_name = 'Last' - token = uuid4() - - class Meta: - model = 'users.User' - django_get_or_create = ['email'] diff --git a/{{cookiecutter.project_slug}}/backend/users/tests/test_api.py b/{{cookiecutter.project_slug}}/backend/users/tests/test_api.py deleted file mode 100644 index af98740..0000000 --- a/{{cookiecutter.project_slug}}/backend/users/tests/test_api.py +++ /dev/null @@ -1,121 +0,0 @@ -from uuid import uuid4 - -from test_plus.test import TestCase - -from ..models import User -from .factories import UserFactory - - -class UserTests(TestCase): - - def setUp(self): - self.user = UserFactory() - self.another_user = UserFactory() - - def test_profile(self): - # wihout login - response = self.get('user-profile') - self.response_401(response) - - # with login - with self.login(username=self.user.email): - response = self.get('user-profile') - self.response_200(response) - - def test_login(self): - # success - data = {'email': self.user.email, 'password': 'password'} - response = self.post('user-login', data=data) - self.response_200(response) - - # fail - data = {'email': self.user.email, 'password': 'bad_password'} - response = self.post('user-login', data=data) - self.response_404(response) - - def test_list(self): - with self.login(username=self.user.email): - response = self.get('user-list') - self.response_200(response) - self.assertEqual(len(response.json()), 2) - - def test_detail(self): - with self.login(username=self.user.email): - response = self.get('user-detail', pk=self.user.id) - self.response_200(response) - - def test_delete(self): - with self.login(username=self.user.email): - response = self.delete('user-detail', pk=self.user.id) - self.assertEqual(response.status_code, 204) - - def test_update(self): - with self.login(username=self.user.email): - response = self.get('user-detail', pk=self.user.id, data={'password': 'new_password'}) - self.response_200(response) - self.assertEqual(self.user.password == User.objects.last().password, False) - - def test_create(self): - with self.login(username=self.user.email): - data = { - 'email': 'test_create@gmail.com', - 'password': 'password', - 'first_name': 'first_name', - 'last_name': 'last_name' - } - response = self.post('user-list', data=data) - self.response_201(response) - self.assertEqual(response.json()['email'], 'test_create@gmail.com') - - def test_register(self): - with self.login(username=self.user.email): - UserFactory(email='test_reg@gmail.com') - - # user with email already exists - data = { - 'email': 'test_reg@gmail.com', - 'password': 'password', - 'first_name': 'first', - 'last_name': 'last', - 'avatar': '' - } - response = self.post('user-register', data=data) - self.response_200(response) - self.assertEqual(response.json()['status'], 210) - - # successful registration - data = { - 'email': 'test_reg_new@gmail.com', - 'password': 'password', - 'first_name': 'first', - 'last_name': 'last', - 'avatar': '' - } - response = self.post('user-register', data=data) - self.response_201(response) - - def test_password_reset(self): - with self.login(username=self.user.email): - # successful password reset - response = self.post('user-password-reset', data={'email': self.user.email}) - self.response_200(response) - - # email doesn't exist - response = self.post('user-password-reset', data={'email': 'fake-email@gmail.com'}) - self.response_404(response) - - def test_password_change(self): - with self.login(username=self.user.email): - current_password = self.user.password - - # token doesn't exist - response = self.post('user-password-change', data={'token': uuid4(), 'password': 'new_password'}) - self.response_404(response) - self.assertEqual(current_password, self.user.password) - - # successful password change - self.user.token = uuid4() - self.user.save() - response = self.post('user-password-change', data={'token': self.user.token, 'password': 'new_password'}) - self.response_200(response) - self.assertEqual(current_password == User.objects.last().password, False) diff --git a/{{cookiecutter.project_slug}}/backend/users/tests/test_models.py b/{{cookiecutter.project_slug}}/backend/users/tests/test_models.py deleted file mode 100644 index e272298..0000000 --- a/{{cookiecutter.project_slug}}/backend/users/tests/test_models.py +++ /dev/null @@ -1,34 +0,0 @@ -from test_plus.test import TestCase - -from ..models import User -from .factories import UserFactory - - -class TestUser(TestCase): - user_factory = UserFactory - - def setUp(self): - self.user = self.make_user() - - def test_full_name(self): - self.assertEqual(self.user.get_full_name(), 'First Last') - - def test_short_name(self): - self.assertEqual(self.user.get_short_name(), 'Last F.') - - def test_string_representation(self): - self.assertEqual(str(self.user), 'First Last') - - def test_create(self): - extra_fields = {'email': 'test@email.com', 'password': 'password', 'first_name': 'test'} - user = User.objects.create(**extra_fields) - self.assertEqual(user.email, 'test@email.com') - self.assertEqual(user.first_name, 'test') - - def test_create_user(self): - user = User.objects.create_user(email='user@email.com', password='password') - self.assertEqual(user.email, 'user@email.com') - - def test_create_superuser(self): - user = User.objects.create_superuser(email='user@email.com', password='password') - self.assertEqual(user.email, 'user@email.com') diff --git a/{{cookiecutter.project_slug}}/docker-compose-prod.yml b/{{cookiecutter.project_slug}}/docker-compose-prod.yml new file mode 100644 index 0000000..801bf46 --- /dev/null +++ b/{{cookiecutter.project_slug}}/docker-compose-prod.yml @@ -0,0 +1,59 @@ +version: '3.3' + +volumes: + postgres_data: {} + portainer_data: {} + +services: + backend: + build: + context: ./backend + depends_on: + - postgres + volumes: + - ./backend:/app + command: /gunicorn.sh + entrypoint: /entrypoint.sh + restart: on-failure + env_file: .env + + postgres: + image: postgres:10-alpine + volumes: + - postgres_data:/var/lib/postgresql/data + env_file: .env + + nginx: + build: + context: . + dockerfile: nginx/Dockerfile + ports: + - "8000:80" + depends_on: + - backend + volumes: + - ./backend/media/:/media/ + - ./backend/staticfiles/:/staticfiles/ + - ./nginx/prod.conf:/etc/nginx/nginx.conf:ro + + +{% if cookiecutter.backups == 'y' %} + backups: + image: prodrigestivill/postgres-backup-local + restart: on-failure + depends_on: + - postgres + volumes: + - /tmp/backups/:/backups/ +{% endif %} + +{% if cookiecutter.use_portainer == 'y' %} + portainer: + image: portainer/portainer + ports: + - "9000:9000" + command: -H unix:///var/run/docker.sock + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - portainer_data:/data +{% endif %} diff --git a/{{cookiecutter.project_slug}}/docker-compose.yml b/{{cookiecutter.project_slug}}/docker-compose.yml new file mode 100644 index 0000000..b9af33a --- /dev/null +++ b/{{cookiecutter.project_slug}}/docker-compose.yml @@ -0,0 +1,54 @@ +version: '3.3' + +volumes: + postgres_data: {} + +services: + backend: + build: + context: ./backend + depends_on: + - postgres + volumes: + - ./backend:/app + command: /start.sh + entrypoint: /entrypoint.sh + restart: on-failure + env_file: .env + + frontend: + image: node:10-alpine + command: npm run serve + volumes: + - ./.env:/app/.env:ro + - ./frontend:/app + working_dir: /app + restart: on-failure + + postgres: + image: postgres:10-alpine + volumes: + - postgres_data:/var/lib/postgresql/data + env_file: .env + +{% if cookiecutter.use_mailhog == 'y' %} + mailhog: + image: mailhog/mailhog + ports: + - "8025:8025" + logging: + driver: none +{% endif %} + + nginx: + image: nginx:alpine + ports: + - "8000:80" + depends_on: + - backend + volumes: + - ./backend/media/:/media/ + - ./backend/staticfiles/:/staticfiles/ + - ./nginx/dev.conf:/etc/nginx/nginx.conf:ro + logging: + driver: none diff --git a/{{cookiecutter.project_slug}}/env.example b/{{cookiecutter.project_slug}}/env.example index 623c31e..6aefed2 100644 --- a/{{cookiecutter.project_slug}}/env.example +++ b/{{cookiecutter.project_slug}}/env.example @@ -1,12 +1,16 @@ +# NOTICE: +# Vue app will only detect VUE_APP_* envs + # Django -DEBUG=True # change for production +DEBUG=True SECRET_KEY=CHANGEME!!! -DOMAIN=http://localhost:8000 # change for production -ALLOWED_HOSTS=* # change for production +DOMAIN=http://localhost:8000 +ALLOWED_HOSTS=* -EMAIL_PORT=1025 # change for production -EMAIL_HOST=mailhog # change for production +# Email settings, defaults to 1025 and mailhog +#EMAIL_PORT=25 +#EMAIL_HOST=localhost # PostgreSQL POSTGRES_DB={{cookiecutter.project_slug}} @@ -17,14 +21,15 @@ POSTGRES_USER=postgresuser # Sentry SENTRY_DSN= SENTRY_PUBLIC_DSN= +VUE_APP_SENTRY_PUBLIC_DSN= {% endif %} {% if cookiecutter.analytics == 'Google Analytics' -%} # Google Analytics -GOOGLE_ANALYTICS=UA-XXXXXXXXX-X +VUE_APP_GOOGLE_ANALYTICS=UA-XXXXXXXXX-X {% endif %} {% if cookiecutter.analytics == 'Yandex Metrika' -%} # Yandex Metrika -YANDEX_METRIKA= +VUE_APP_YANDEX_METRIKA= {% endif %} diff --git a/{{cookiecutter.project_slug}}/frontend/babel.config.js b/{{cookiecutter.project_slug}}/frontend/babel.config.js new file mode 100644 index 0000000..ba17966 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/babel.config.js @@ -0,0 +1,5 @@ +module.exports = { + presets: [ + '@vue/app' + ] +} diff --git a/{{cookiecutter.project_slug}}/frontend/images/default_avatar.png b/{{cookiecutter.project_slug}}/frontend/images/default_avatar.png deleted file mode 100644 index e397a40..0000000 Binary files a/{{cookiecutter.project_slug}}/frontend/images/default_avatar.png and /dev/null differ diff --git a/{{cookiecutter.project_slug}}/frontend/images/favicons/android-chrome-192x192.png b/{{cookiecutter.project_slug}}/frontend/images/favicons/android-chrome-192x192.png deleted file mode 100644 index 3516c02..0000000 Binary files a/{{cookiecutter.project_slug}}/frontend/images/favicons/android-chrome-192x192.png and /dev/null differ diff --git a/{{cookiecutter.project_slug}}/frontend/images/favicons/android-chrome-384x384.png b/{{cookiecutter.project_slug}}/frontend/images/favicons/android-chrome-384x384.png deleted file mode 100644 index 45cbc0a..0000000 Binary files a/{{cookiecutter.project_slug}}/frontend/images/favicons/android-chrome-384x384.png and /dev/null differ diff --git a/{{cookiecutter.project_slug}}/frontend/images/favicons/apple-touch-icon.png b/{{cookiecutter.project_slug}}/frontend/images/favicons/apple-touch-icon.png deleted file mode 100644 index 7b3239f..0000000 Binary files a/{{cookiecutter.project_slug}}/frontend/images/favicons/apple-touch-icon.png and /dev/null differ diff --git a/{{cookiecutter.project_slug}}/frontend/images/favicons/browserconfig.xml b/{{cookiecutter.project_slug}}/frontend/images/favicons/browserconfig.xml deleted file mode 100644 index 5cd27e3..0000000 --- a/{{cookiecutter.project_slug}}/frontend/images/favicons/browserconfig.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - #603cba - - - diff --git a/{{cookiecutter.project_slug}}/frontend/images/favicons/favicon-16x16.png b/{{cookiecutter.project_slug}}/frontend/images/favicons/favicon-16x16.png deleted file mode 100644 index 68eadbd..0000000 Binary files a/{{cookiecutter.project_slug}}/frontend/images/favicons/favicon-16x16.png and /dev/null differ diff --git a/{{cookiecutter.project_slug}}/frontend/images/favicons/favicon-32x32.png b/{{cookiecutter.project_slug}}/frontend/images/favicons/favicon-32x32.png deleted file mode 100644 index dde01f3..0000000 Binary files a/{{cookiecutter.project_slug}}/frontend/images/favicons/favicon-32x32.png and /dev/null differ diff --git a/{{cookiecutter.project_slug}}/frontend/images/favicons/favicon.ico b/{{cookiecutter.project_slug}}/frontend/images/favicons/favicon.ico deleted file mode 100644 index e481e5d..0000000 Binary files a/{{cookiecutter.project_slug}}/frontend/images/favicons/favicon.ico and /dev/null differ diff --git a/{{cookiecutter.project_slug}}/frontend/images/favicons/manifest.json b/{{cookiecutter.project_slug}}/frontend/images/favicons/manifest.json deleted file mode 100644 index f784358..0000000 --- a/{{cookiecutter.project_slug}}/frontend/images/favicons/manifest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "", - "icons": [ - { - "src": "/android-chrome-192x192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/android-chrome-384x384.png", - "sizes": "384x384", - "type": "image/png" - } - ], - "theme_color": "#ffffff", - "background_color": "#ffffff", - "display": "standalone" -} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/frontend/images/favicons/mstile-150x150.png b/{{cookiecutter.project_slug}}/frontend/images/favicons/mstile-150x150.png deleted file mode 100644 index ab41d50..0000000 Binary files a/{{cookiecutter.project_slug}}/frontend/images/favicons/mstile-150x150.png and /dev/null differ diff --git a/{{cookiecutter.project_slug}}/frontend/images/favicons/safari-pinned-tab.svg b/{{cookiecutter.project_slug}}/frontend/images/favicons/safari-pinned-tab.svg deleted file mode 100644 index dc0b992..0000000 --- a/{{cookiecutter.project_slug}}/frontend/images/favicons/safari-pinned-tab.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - -Created by potrace 1.11, written by Peter Selinger 2001-2013 - - - - - diff --git a/{{cookiecutter.project_slug}}/frontend/main.html b/{{cookiecutter.project_slug}}/frontend/main.html deleted file mode 100644 index b50fd37..0000000 --- a/{{cookiecutter.project_slug}}/frontend/main.html +++ /dev/null @@ -1,48 +0,0 @@ - -{% raw %} -{% load static %} -{% endraw %} - - - - {{cookiecutter.project_name}} - - - - - - - - - - {% raw -%} - - - - - - - - {% endraw -%} - - - - - - - -
- - - {% if cookiecutter.analytics == 'Google Analytics' -%}{% raw %}{% if GOOGLE_ANALYTICS %}{% endif %}{% endraw %}{% endif %} - {% if cookiecutter.analytics == 'Yandex Metrika' -%}{% raw %}{% endraw %}{% endif %} - {% if cookiecutter.use_sentry == 'y' %}{% raw %}{% endraw %}{% endif %} - - {% raw %} - {% if DEBUG %} - - {% else %} - - {% endif %} - {% endraw %} - diff --git a/{{cookiecutter.project_slug}}/frontend/package-lock.json b/{{cookiecutter.project_slug}}/frontend/package-lock.json new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/frontend/package.json b/{{cookiecutter.project_slug}}/frontend/package.json index 0826654..4a82066 100644 --- a/{{cookiecutter.project_slug}}/frontend/package.json +++ b/{{cookiecutter.project_slug}}/frontend/package.json @@ -1,48 +1,86 @@ { - "name": "{{cookiecutter.project_slug}}", - "description": "{{cookiecutter.description}}", + "name": "frontend", "version": "0.1.0", - "author": "{{cookiecutter.author}}", - {% if cookiecutter.license != 'Not open source' %}"license": "{{cookiecutter.license}}",{% endif %} - {% if cookiecutter.license == 'Not open source' %}"private": true,{% endif %} + "private": true, "scripts": { - "dev": "cross-env NODE_ENV=development webpack-dev-server", - "build": "cross-env NODE_ENV=production webpack --progress --hide-modules", - "lint": "eslint --ext .js,.vue {{cookiecutter.project_slug}}/static" + "serve": "npm i && vue-cli-service serve", + "build": "vue-cli-service build", + "lint": "vue-cli-service lint", + "test:unit": "vue-cli-service test:unit" }, "dependencies": { - "axios": "^0.16.2", - "babel-core": "^6.25.0", - "babel-eslint": "^8.2.1", - "babel-loader": "^7.0.0", - "babel-polyfill": "^6.26.0", - "babel-preset-env": "^1.5.2", - "cross-env": "^5.0.1", - "css-loader": "^0.28.4", - "eslint": "^4.15.0", - "eslint-config-standard": "^10.2.1", - "eslint-friendly-formatter": "^3.0.0", - "eslint-loader": "^1.7.1", - "eslint-plugin-import": "^2.7.0", - "eslint-plugin-node": "^5.2.0", - "eslint-plugin-promise": "^3.4.0", - "eslint-plugin-standard": "^3.0.1", - "eslint-plugin-vue": "^4.0.0", - "file-loader": "^1.1.4", - "foundation-sites": "^6.4.1", - "node-sass": "^4.5.3", - "sass-loader": "^6.0.6", - "url-loader": "^0.5.8", - "vue": "^2.3.4", - {% if cookiecutter.analytics == "Google Analytics" %}"vue-analytics": "^5.3.2",{% endif %} - "vue-loader": "^13.3.0", - "vue-meta": "^1.2.0", - {% if cookiecutter.use_sentry == 'y' %}"vue-raven": "^0.1.0",{% endif %} - "vue-router": "^2.7.0", - "vue-template-compiler": "^2.3.4", - {% if cookiecutter.analytics == "Yandex Metrika" %}"vue-yandex-metrika": "^1.4.0",{% endif %} - "vuex": "^2.3.1", - "webpack": "^2.6.1", - "webpack-dev-server": "^2.4.5" + "axios": "^0.18.0", + "register-service-worker": "^1.5.2", + "vue": "^2.5.17", + {% if cookiecutter.api == "GraphQL" %}"vue-apollo": "^3.0.0-beta.20",{% endif %} + "vue-router": "^3.0.1", + {% if cookiecutter.use_sentry == 'y' %}"vue-raven": "^1.0.0",{% endif %} + {% if cookiecutter.analytics == "Google Analytics" %}"vue-analytics": "^5.16.0",{% endif %} + {% if cookiecutter.analytics == "Yandex Metrika" %}{% endif %} + "vuex": "^3.0.1" + }, + "devDependencies": { + "@vue/cli-plugin-babel": "^3.0.1", + "@vue/cli-plugin-eslint": "^3.0.1", + "@vue/cli-plugin-pwa": "^3.0.1", + "@vue/cli-plugin-unit-jest": "^3.0.1", + "@vue/cli-service": "^3.0.1", + "@vue/eslint-config-standard": "^3.0.1", + "@vue/test-utils": "^1.0.0-beta.24", + "babel-core": "^6.26.3", + "babel-jest": "^23.4.2", + "node-sass": "^4.9.3", + {% if cookiecutter.api == "GraphQL" %} + "graphql-tag": "^2.9.2", + "vue-cli-plugin-apollo": "^0.16.4", + {% endif %} + "sass-loader": "^7.1.0", + "vue-template-compiler": "^2.5.17" + }, + "eslintConfig": { + "root": true, + "env": { + "node": true + }, + "extends": [ + "plugin:vue/essential", + "@vue/standard" + ], + "rules": {}, + "parserOptions": { + "parser": "babel-eslint" + } + }, + "postcss": { + "plugins": { + "autoprefixer": {} + } + }, + "browserslist": [ + "> 1%", + "last 2 versions", + "not ie <= 8" + ], + "jest": { + "moduleFileExtensions": [ + "js", + "jsx", + "json", + "vue" + ], + "transform": { + "^.+\\.vue$": "vue-jest", + ".+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$": "jest-transform-stub", + "^.+\\.jsx?$": "babel-jest" + }, + "moduleNameMapper": { + "^@/(.*)$": "/src/$1" + }, + "snapshotSerializers": [ + "jest-serializer-vue" + ], + "testMatch": [ + "/(tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx))" + ] } } diff --git a/{{cookiecutter.project_slug}}/frontend/public/img/icons/android-chrome-192x192.png b/{{cookiecutter.project_slug}}/frontend/public/img/icons/android-chrome-192x192.png new file mode 100644 index 0000000..b02aa64 Binary files /dev/null and b/{{cookiecutter.project_slug}}/frontend/public/img/icons/android-chrome-192x192.png differ diff --git a/{{cookiecutter.project_slug}}/frontend/public/img/icons/android-chrome-512x512.png b/{{cookiecutter.project_slug}}/frontend/public/img/icons/android-chrome-512x512.png new file mode 100644 index 0000000..06088b0 Binary files /dev/null and b/{{cookiecutter.project_slug}}/frontend/public/img/icons/android-chrome-512x512.png differ diff --git a/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-120x120.png b/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-120x120.png new file mode 100644 index 0000000..1427cf6 Binary files /dev/null and b/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-120x120.png differ diff --git a/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-152x152.png b/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-152x152.png new file mode 100644 index 0000000..f24d454 Binary files /dev/null and b/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-152x152.png differ diff --git a/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-180x180.png b/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-180x180.png new file mode 100644 index 0000000..404e192 Binary files /dev/null and b/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-180x180.png differ diff --git a/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-60x60.png b/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-60x60.png new file mode 100644 index 0000000..cf10a56 Binary files /dev/null and b/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-60x60.png differ diff --git a/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-76x76.png b/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-76x76.png new file mode 100644 index 0000000..c500769 Binary files /dev/null and b/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-76x76.png differ diff --git a/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon.png b/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon.png new file mode 100644 index 0000000..03c0c5d Binary files /dev/null and b/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon.png differ diff --git a/{{cookiecutter.project_slug}}/frontend/public/img/icons/favicon-16x16.png b/{{cookiecutter.project_slug}}/frontend/public/img/icons/favicon-16x16.png new file mode 100644 index 0000000..42af009 Binary files /dev/null and b/{{cookiecutter.project_slug}}/frontend/public/img/icons/favicon-16x16.png differ diff --git a/{{cookiecutter.project_slug}}/frontend/public/img/icons/favicon-32x32.png b/{{cookiecutter.project_slug}}/frontend/public/img/icons/favicon-32x32.png new file mode 100644 index 0000000..46ca04d Binary files /dev/null and b/{{cookiecutter.project_slug}}/frontend/public/img/icons/favicon-32x32.png differ diff --git a/{{cookiecutter.project_slug}}/frontend/public/img/icons/msapplication-icon-144x144.png b/{{cookiecutter.project_slug}}/frontend/public/img/icons/msapplication-icon-144x144.png new file mode 100644 index 0000000..7808237 Binary files /dev/null and b/{{cookiecutter.project_slug}}/frontend/public/img/icons/msapplication-icon-144x144.png differ diff --git a/{{cookiecutter.project_slug}}/frontend/public/img/icons/mstile-150x150.png b/{{cookiecutter.project_slug}}/frontend/public/img/icons/mstile-150x150.png new file mode 100644 index 0000000..3b37a43 Binary files /dev/null and b/{{cookiecutter.project_slug}}/frontend/public/img/icons/mstile-150x150.png differ diff --git a/{{cookiecutter.project_slug}}/frontend/public/img/icons/safari-pinned-tab.svg b/{{cookiecutter.project_slug}}/frontend/public/img/icons/safari-pinned-tab.svg new file mode 100644 index 0000000..732afd8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/public/img/icons/safari-pinned-tab.svg @@ -0,0 +1,149 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + diff --git a/{{cookiecutter.project_slug}}/frontend/public/index.html b/{{cookiecutter.project_slug}}/frontend/public/index.html new file mode 100644 index 0000000..9c47558 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/public/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + {{ cookiecutter.project_name }} + + + +
+ + + diff --git a/{{cookiecutter.project_slug}}/frontend/public/manifest.json b/{{cookiecutter.project_slug}}/frontend/public/manifest.json new file mode 100644 index 0000000..c4cb1e1 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/public/manifest.json @@ -0,0 +1,20 @@ +{ + "name": "{{ cookiecutter.project_slug }}", + "short_name": "{{ cookiecutter.project_slug }}", + "icons": [ + { + "src": "/img/icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/img/icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "start_url": "", + "display": "standalone", + "background_color": "#000000", + "theme_color": "#4DBA87" +} diff --git a/{{cookiecutter.project_slug}}/frontend/public/robots.txt b/{{cookiecutter.project_slug}}/frontend/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/{{cookiecutter.project_slug}}/frontend/scss/_settings.scss b/{{cookiecutter.project_slug}}/frontend/scss/_settings.scss deleted file mode 100644 index 0ea8dac..0000000 --- a/{{cookiecutter.project_slug}}/frontend/scss/_settings.scss +++ /dev/null @@ -1,163 +0,0 @@ -// Foundation for Sites Settings -// ----------------------------- - -@import '~foundation-sites/scss/util/util'; - -// 1. Global -// --------- - -$global-font-size: 100%; -$global-width: rem-calc(1345); -$global-lineheight: 1.2; -$foundation-palette: ( - main: #424770, - hover: #32325d, - link: #0f92df, - primary: #009fe6, - secondary: #a6b3b6, - header: #ececec, - button-main: #009ddb, - secondary2: #1bbe00, - success: #3adb76, - warning: #e8980f, - alert: #ff7249, - sidebar-bg: #80888a, - free: #33b600 -); - -$gray: ( - gray1: #e8e8e8, - gray2: #d8d8d8, - gray3: #c8c8c8 -); - -$black: #000000; -$white: #ffffff; -$light-gray: #e6e6e6; -$medium-gray: #dadada; -$dark-gray: #141414; -$red: #e14252; -$yellow: #e0994c; -$green: #24b47e; -$select-color: #525f7f; -$btn-color: #555abf; -$background-color: #f1f5f9; - -$body-background: $white; -$body-font-color: #424770; -$body-font-family: 'Open Sans', sans-serif; -$body-font-family-bold: 'Open Sans', sans-serif; -$body-font-family-semibold: 'Open Sans', sans-serif; -$body-antialiased: true; -$global-margin: 1rem; -$global-padding: 1rem; -$global-weight-normal: normal; -$global-weight-bold: bold; -$global-radius: 0; -$btn-radius: 4px; -$global-text-direction: ltr; -$global-flexbox: false; -$print-transparent-backgrounds: true; - -@include add-foundation-colors; - -// 2. Breakpoints -// -------------- - -$breakpoints: ( - small: 0, - medium: 640px, - large: 1024px, - xlarge: 1440px, - xxlarge: 1920px -); -$print-breakpoint: large; -$breakpoint-classes: (small medium large); - -// 3. The Grid -// ----------- - -$grid-row-width: $global-width; -$grid-column-count: 12; -$grid-column-gutter: ( - small: 20px, - medium: 30px -); -$grid-column-align-edge: true; -$block-grid-max: 8; - -// 4. Base Typography -// ------------------ - -$header-font-family: $body-font-family-bold; -$header-font-weight: $global-weight-normal; -$header-font-style: normal; -$font-family-monospace: Consolas, 'Liberation Mono', Courier, monospace; -$header-color: inherit; -$header-lineheight: 1.2; -$header-margin-bottom: 1rem; -$header-styles: ( - small: ( - 'h1': ('font-size': 24), - 'h2': ('font-size': 20), - 'h3': ('font-size': 19), - 'h4': ('font-size': 18), - 'h5': ('font-size': 17), - 'h6': ('font-size': 16) - ), - medium: ( - 'h1': ('font-size': 44), - 'h2': ('font-size': 30), - 'h3': ('font-size': 24), - 'h4': ('font-size': 20), - 'h5': ('font-size': 18), - 'h6': ('font-size': 16) - ), -); - -$header-text-rendering: optimizeLegibility; -$small-font-size: 80%; -$header-small-font-color: $medium-gray; -$paragraph-lineheight: 1.5; -$paragraph-margin-bottom: rem-calc(20); -$paragraph-text-rendering: optimizeLegibility; -$code-color: $black; -$code-font-family: $font-family-monospace; -$code-font-weight: $global-weight-normal; -$code-background: $light-gray; -$code-border: 1px solid $medium-gray; -$code-padding: rem-calc(2 5 1); -$anchor-color: #0f92df; -$anchor-color-hover: #424770; -$anchor-text-decoration: none; -$anchor-text-decoration-hover: underline; -$hr-width: 100%; -$hr-border: 1px solid map-get($gray, gray2); -$hr-margin: rem-calc(20) auto; -$list-lineheight: $paragraph-lineheight; -$list-margin-bottom: $paragraph-margin-bottom; -$list-style-type: disc; -$list-style-position: outside; -$list-side-margin: 1.25rem; -$list-nested-side-margin: 1.25rem; -$defnlist-margin-bottom: 1rem; -$defnlist-term-weight: $global-weight-bold; -$defnlist-term-margin-bottom: 0.3rem; -$blockquote-color: $dark-gray; -$blockquote-padding: rem-calc(9 20 0 19); -$blockquote-border: 1px solid $medium-gray; -$cite-font-size: rem-calc(13); -$cite-color: $dark-gray; -$cite-pseudo-content: '\2014 \0020'; -$keystroke-font: $font-family-monospace; -$keystroke-color: $black; -$keystroke-background: $light-gray; -$keystroke-padding: rem-calc(2 4 0); -$keystroke-radius: $global-radius; -$abbr-underline: 1px dotted $black; - -// 5. Forms -// --------- - -$input-border: 1px solid $medium-gray; -$input-border-focus: 1px solid $primary-color; diff --git a/{{cookiecutter.project_slug}}/frontend/Main.vue b/{{cookiecutter.project_slug}}/frontend/src/App.vue similarity index 68% rename from {{cookiecutter.project_slug}}/frontend/Main.vue rename to {{cookiecutter.project_slug}}/frontend/src/App.vue index 27d761a..f63fb4d 100644 --- a/{{cookiecutter.project_slug}}/frontend/Main.vue +++ b/{{cookiecutter.project_slug}}/frontend/src/App.vue @@ -6,8 +6,6 @@ - - diff --git a/{{cookiecutter.project_slug}}/frontend/src/apollo.js b/{{cookiecutter.project_slug}}/frontend/src/apollo.js new file mode 100644 index 0000000..031f4f6 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/apollo.js @@ -0,0 +1,70 @@ +import Vue from 'vue' +import VueApollo from 'vue-apollo' +import { createApolloClient, restartWebsockets } from 'vue-cli-plugin-apollo/graphql-client' + +// Install the vue plugin +Vue.use(VueApollo) + +// Name of the localStorage item +const AUTH_TOKEN = 'jwt-token' + +// Config +const defaultOptions = { + httpEndpoint: '/graphql', + wsEndpoint: null, + tokenName: AUTH_TOKEN, + persisting: false, + websocketsOnly: false, + ssr: false +} + +// Call this in the Vue app file +export function createProvider (options = {}) { + // Create apollo client + const { apolloClient, wsClient } = createApolloClient({ + ...defaultOptions, + ...options + }) + apolloClient.wsClient = wsClient + + // Create vue apollo provider + const apolloProvider = new VueApollo({ + defaultClient: apolloClient, + defaultOptions: { + $query: { + loadingKey: 'loading', + fetchPolicy: 'cache-and-network' + } + }, + errorHandler (error) { + // eslint-disable-next-line no-console + console.log('%cError', 'background: red; color: white; padding: 2px 4px; border-radius: 3px; font-weight: bold;', error.message) + } + }) + + return apolloProvider +} + +// Manually call this when user log in +export async function onLogin (apolloClient, token) { + localStorage.setItem(AUTH_TOKEN, token) + if (apolloClient.wsClient) restartWebsockets(apolloClient.wsClient) + try { + await apolloClient.resetStore() + } catch (e) { + // eslint-disable-next-line no-console + console.log('%cError on cache reset (login)', 'color: orange;', e.message) + } +} + +// Manually call this when user log out +export async function onLogout (apolloClient) { + localStorage.removeItem(AUTH_TOKEN) + if (apolloClient.wsClient) restartWebsockets(apolloClient.wsClient) + try { + await apolloClient.resetStore() + } catch (e) { + // eslint-disable-next-line no-console + console.log('%cError on cache reset (logout)', 'color: orange;', e.message) + } +} diff --git a/{{cookiecutter.project_slug}}/frontend/components/FirstComponent.vue b/{{cookiecutter.project_slug}}/frontend/src/components/ExampleComponent.vue similarity index 100% rename from {{cookiecutter.project_slug}}/frontend/components/FirstComponent.vue rename to {{cookiecutter.project_slug}}/frontend/src/components/ExampleComponent.vue diff --git a/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/login.gql b/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/login.gql new file mode 100644 index 0000000..c1d5107 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/login.gql @@ -0,0 +1,5 @@ +mutation login ($email: String!, $password: String!) { + login (email: $email, password: $password) { + token + } +} diff --git a/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/logout.gql b/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/logout.gql new file mode 100644 index 0000000..9440265 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/logout.gql @@ -0,0 +1,5 @@ +mutation logout { + logout { + success + } +} diff --git a/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/refreshToken.gql b/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/refreshToken.gql new file mode 100644 index 0000000..abd715f --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/refreshToken.gql @@ -0,0 +1,6 @@ +mutation refreshToken ($token: String!) { + refreshToken (token: $token) { + token + payload + } +} diff --git a/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/register.gql b/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/register.gql new file mode 100644 index 0000000..b35c59a --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/register.gql @@ -0,0 +1,6 @@ +mutation register ($email: String!, $password: String!, $firstName: String!, $lastName: String!) { + register (email: $email, password: $password, firstName: $firstName, lastName: $lastName) { + success + errors + } +} diff --git a/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/resetPassword.gql b/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/resetPassword.gql new file mode 100644 index 0000000..4bf6987 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/resetPassword.gql @@ -0,0 +1,5 @@ +mutation resetPassword ($email: String!) { + resetPassword (email: $email) { + success + } +} diff --git a/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/resetPasswordConfirm.gql b/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/resetPasswordConfirm.gql new file mode 100644 index 0000000..97219e0 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/resetPasswordConfirm.gql @@ -0,0 +1,6 @@ +mutation resetPasswordConfirm ($token: String!, $password: String!) { + resetPasswordConfirm (token: $token, password: $password) { + success + errors + } +} diff --git a/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/verifyToken.gql b/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/verifyToken.gql new file mode 100644 index 0000000..6c0c0d6 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/graphql/mutations/verifyToken.gql @@ -0,0 +1,5 @@ +mutation verifyToken ($token: String!) { + verifyToken (token: $token) { + payload + } +} diff --git a/{{cookiecutter.project_slug}}/frontend/src/graphql/queries/profile.gql b/{{cookiecutter.project_slug}}/frontend/src/graphql/queries/profile.gql new file mode 100644 index 0000000..c19c804 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/graphql/queries/profile.gql @@ -0,0 +1,8 @@ +query profile { + profile { + id + email + firstName + lastName + } +} diff --git a/{{cookiecutter.project_slug}}/frontend/main.js b/{{cookiecutter.project_slug}}/frontend/src/main.js similarity index 53% rename from {{cookiecutter.project_slug}}/frontend/main.js rename to {{cookiecutter.project_slug}}/frontend/src/main.js index 0ea0214..c7d330f 100644 --- a/{{cookiecutter.project_slug}}/frontend/main.js +++ b/{{cookiecutter.project_slug}}/frontend/src/main.js @@ -1,39 +1,51 @@ import Vue from 'vue' +import store from '@/store' +import router from '@/router' +{% if cookiecutter.api == "REST" %} import axios from 'axios' -import router from './router' -import {store} from './store' -import Meta from 'vue-meta' +axios.defaults.xsrfCookieName = 'csrftoken' +axios.defaults.xsrfHeaderName = 'X-CSRFToken' +{% elif cookiecutter.api == "GraphQL" %} +import { createProvider } from '@/apollo' +{% endif %} + {% if cookiecutter.analytics == 'Google Analytics' %}import VueAnalytics from 'vue-analytics'{% endif %} {% if cookiecutter.analytics == 'Yandex Metrika' %}import VueYandexMetrika from 'vue-yandex-metrika'{% endif %} {% if cookiecutter.use_sentry == 'y' %}import VueRaven from 'vue-raven'{% endif %} -import Main from './Main.vue' +import App from '@/App.vue' +import './registerServiceWorker' -// Axios csrf settings -axios.defaults.xsrfCookieName = 'csrftoken' -axios.defaults.xsrfHeaderName = 'X-CSRFToken' +Vue.config.productionTip = false {% if cookiecutter.use_sentry == 'y' %} // Sentry for logging frontend errors -if (process.env.NODE_ENV === 'production') {Vue.use(VueRaven, {dsn: SENTRY_PUBLIC_DSN})} +if (process.env.NODE_ENV === 'production') { + Vue.use(VueRaven, {dsn: process.env.VUE_APP_SENTRY_PUBLIC_DSN}) +} {% endif %} {% if cookiecutter.analytics == 'Google Analytics' %} // more info: https://github.com/MatteoGabriele/vue-analytics -Vue.use(VueAnalytics, {id: GOOGLE_ANALYTICS, router}) +Vue.use(VueAnalytics, { + id: process.env.VUE_APP_GOOGLE_ANALYTICS, + router +}) {% endif %} + {% if cookiecutter.analytics == 'Yandex Metrika' %} // more info: https://github.com/vchaptsev/vue-yandex-metrika -Vue.use(VueYandexMetrika, {id: YANDEX_METRIKA, env: process.env.NODE_ENV, router}) +Vue.use(VueYandexMetrika, { + id: process.env.VUE_APP_YANDEX_METRIKA, + env: process.env.NODE_ENV, + router +}) {% endif %} -Vue.use(Meta) - -/* eslint-disable no-new */ new Vue({ - el: '#main', router, store, - render: h => h(Main) -}) + {% if cookiecutter.api == "GraphQL" %}provide: createProvider().provide(),{% endif %} + render: h => h(App) +}).$mount('#app') diff --git a/{{cookiecutter.project_slug}}/frontend/src/registerServiceWorker.js b/{{cookiecutter.project_slug}}/frontend/src/registerServiceWorker.js new file mode 100644 index 0000000..fc23027 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/registerServiceWorker.js @@ -0,0 +1,25 @@ +/* eslint-disable no-console */ +import { register } from 'register-service-worker' + +if (process.env.NODE_ENV === 'production') { + register(`${process.env.BASE_URL}service-worker.js`, { + ready () { + console.log( + 'App is being served from cache by a service worker.\n' + + 'For more details, visit https://goo.gl/AFskqB' + ) + }, + cached () { + console.log('Content has been cached for offline use.') + }, + updated () { + console.log('New content is available; please refresh.') + }, + offline () { + console.log('No internet connection found. App is running in offline mode.') + }, + error (error) { + console.error('Error during service worker registration:', error) + } + }) +} diff --git a/{{cookiecutter.project_slug}}/frontend/router.js b/{{cookiecutter.project_slug}}/frontend/src/router.js similarity index 68% rename from {{cookiecutter.project_slug}}/frontend/router.js rename to {{cookiecutter.project_slug}}/frontend/src/router.js index 0fa6570..61c002f 100644 --- a/{{cookiecutter.project_slug}}/frontend/router.js +++ b/{{cookiecutter.project_slug}}/frontend/src/router.js @@ -1,17 +1,17 @@ -import Vue from 'vue' -import VueRouter from 'vue-router' - -import FirstComponent from './components/FirstComponent.vue' - +import Vue from 'vue' +import VueRouter from 'vue-router' + +import ExampleComponent from '@/components/ExampleComponent.vue' + const routes = [ - {path: '*', component: FirstComponent} -] - -Vue.use(VueRouter) -const router = new VueRouter({ - scrollBehavior (to, from, savedPosition) { return {x: 0, y: 0} }, - mode: 'history', - routes -}) - -export default router + {path: '*', component: ExampleComponent} +] + +Vue.use(VueRouter) +const router = new VueRouter({ + scrollBehavior (to, from, savedPosition) { return {x: 0, y: 0} }, + mode: 'history', + routes +}) + +export default router diff --git a/{{cookiecutter.project_slug}}/frontend/src/store.js b/{{cookiecutter.project_slug}}/frontend/src/store.js new file mode 100644 index 0000000..8ba0dc1 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/store.js @@ -0,0 +1,13 @@ +import Vue from 'vue' +import Vuex from 'vuex' + +Vue.use(Vuex) + +const store = new Vuex.Store({ + strict: true, + state: {}, + mutations: {}, + actions: {} +}) + +export default store diff --git a/{{cookiecutter.project_slug}}/frontend/src/store/index.js b/{{cookiecutter.project_slug}}/frontend/src/store/index.js new file mode 100644 index 0000000..78d435a --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue' +import Vuex from 'vuex' + +import users from '@/store/services/users' +import auth from '@/store/modules/auth' + +Vue.use(Vuex) + +const store = new Vuex.Store({ + modules: { + users, + auth + } +}) + +export default store diff --git a/{{cookiecutter.project_slug}}/frontend/store/modules/auth.js b/{{cookiecutter.project_slug}}/frontend/src/store/modules/auth.js similarity index 100% rename from {{cookiecutter.project_slug}}/frontend/store/modules/auth.js rename to {{cookiecutter.project_slug}}/frontend/src/store/modules/auth.js diff --git a/{{cookiecutter.project_slug}}/frontend/store/services/users.js b/{{cookiecutter.project_slug}}/frontend/src/store/services/users.js similarity index 100% rename from {{cookiecutter.project_slug}}/frontend/store/services/users.js rename to {{cookiecutter.project_slug}}/frontend/src/store/services/users.js diff --git a/{{cookiecutter.project_slug}}/frontend/store/index.js b/{{cookiecutter.project_slug}}/frontend/store/index.js deleted file mode 100644 index 7b18da3..0000000 --- a/{{cookiecutter.project_slug}}/frontend/store/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import Vue from 'vue' -import Vuex from 'vuex' - -{% if cookiecutter.custom_user == 'y' %} -import users from './services/users' -import auth from './modules/auth'{% endif %} - -Vue.use(Vuex) - -export const store = new Vuex.Store({ - modules: { - {% if cookiecutter.custom_user == 'y' %} - users, - auth{% endif %} - } -}) diff --git a/{{cookiecutter.project_slug}}/frontend/tests/unit/.eslintrc.js b/{{cookiecutter.project_slug}}/frontend/tests/unit/.eslintrc.js new file mode 100644 index 0000000..4e51c63 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/tests/unit/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + env: { + jest: true + }, + rules: { + 'import/no-extraneous-dependencies': 'off' + } +} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/frontend/vue.config.js b/{{cookiecutter.project_slug}}/frontend/vue.config.js new file mode 100644 index 0000000..4146417 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/vue.config.js @@ -0,0 +1,20 @@ +// vue.config.js +module.exports = { + lintOnSave: false, + devServer: { + hot: true, + hotOnly: true, + disableHostCheck: true, + historyApiFallback: true, + public: '0.0.0.0:8000', + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', + 'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization' + }, + watchOptions: { + poll: 1000, + ignored: '/app/node_modules/' + } + } +} diff --git a/{{cookiecutter.project_slug}}/jsconfig.json b/{{cookiecutter.project_slug}}/jsconfig.json new file mode 100644 index 0000000..d614f94 --- /dev/null +++ b/{{cookiecutter.project_slug}}/jsconfig.json @@ -0,0 +1,12 @@ +// jsconfig.json +{ + "compilerOptions": { + "target": "ES6", + "module": "commonjs", + "allowSyntheticDefaultImports": true, + "baseUrl": "./", + "paths": { + "@/*": ["frontend/src/*"], + } + } +} diff --git a/{{cookiecutter.project_slug}}/nginx/Dockerfile b/{{cookiecutter.project_slug}}/nginx/Dockerfile new file mode 100644 index 0000000..f2aae08 --- /dev/null +++ b/{{cookiecutter.project_slug}}/nginx/Dockerfile @@ -0,0 +1,19 @@ +# Stage 1 - build frontend app +FROM node:10-alpine as build-deps + +WORKDIR /app/ + +COPY frontend/package.json frontend/package-lock.json /app/ +RUN npm install + +COPY frontend /app/ +COPY .env /app/.env +RUN npm run build + +# Stage 2 - nginx & frontend dist +FROM nginx:alpine + +COPY nginx/prod.conf /etc/nginx/nginx.conf +COPY --from=build-deps /app/dist/ /dist/ + +CMD ["nginx", "-g", "daemon off;"] diff --git a/{{cookiecutter.project_slug}}/nginx/dev.conf b/{{cookiecutter.project_slug}}/nginx/dev.conf new file mode 100644 index 0000000..a659a77 --- /dev/null +++ b/{{cookiecutter.project_slug}}/nginx/dev.conf @@ -0,0 +1,56 @@ +user nginx; +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + client_max_body_size 100m; + + upstream backend { + server backend:8000; + } + + upstream frontend { + server frontend:8080; + } + + server { + listen 80; + charset utf-8; + + # frontend urls + location / { + proxy_redirect off; + proxy_pass http://frontend; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + } + + # frontend dev-server + location /sockjs-node { + proxy_redirect off; + proxy_pass http://frontend; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # backend urls + location ~ ^/(admin|{% if cookiecutter.api == "REST" %}api{% elif cookiecutter.api == "GraphQL" %}graphql{% endif %}) {) { + proxy_redirect off; + proxy_pass http://backend; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + } + + # backend static + location ~ ^/(staticfiles|media)/(.*)$ { + alias /$1/$2; + } + } +} diff --git a/{{cookiecutter.project_slug}}/nginx/prod.conf b/{{cookiecutter.project_slug}}/nginx/prod.conf new file mode 100644 index 0000000..1fc6d59 --- /dev/null +++ b/{{cookiecutter.project_slug}}/nginx/prod.conf @@ -0,0 +1,52 @@ +user nginx; +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + client_max_body_size 100m; + + upstream backend { + server backend:8000; + } + + server { + listen 80; + charset utf-8; + + root /dist/; + index index.html; + + # frontend + location / { + try_files $uri $uri/ @rewrites; + } + + location @rewrites { + rewrite ^(.+)$ /index.html last; + } + + # backend urls + location ~ ^/(admin|{% if cookiecutter.api == "REST" %}api{% elif cookiecutter.api == "GraphQL" %}graphql{% endif %}) { + proxy_redirect off; + proxy_pass http://backend; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + } + + # backend static + location ~ ^/(staticfiles|media)/(.*)$ { + alias /$1/$2; + } + + # Some basic cache-control for static files to be sent to the browser + location ~* \.(?:ico|css|js|gif|jpe?g|png)$ { + expires max; + add_header Pragma public; + add_header Cache-Control "public, must-revalidate, proxy-revalidate"; + } + } +}