├── .gitignore ├── CONTRIBUTING.md ├── README.md ├── accounts ├── __init__.py ├── adapter.py ├── admin.py ├── apps.py ├── forms.py ├── managers.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_customuser_name.py │ └── __init__.py ├── models.py ├── schemas.py ├── serializers.py ├── tests.py ├── urls.py └── views.py ├── blog ├── __init__.py ├── admin.py ├── api_crud.py ├── apps.py ├── endpoints.py ├── managers.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20211021_2321.py │ ├── 0003_auto_20211022_1338.py │ ├── 0004_auto_20211022_1403.py │ ├── 0005_alter_post_options.py │ └── __init__.py ├── models.py ├── schemas.py └── tests.py ├── contact ├── __init__.py ├── admin.py ├── api_crud.py ├── apps.py ├── endpoints.py ├── managers.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_contact_email.py │ └── __init__.py ├── models.py ├── schemas.py ├── tests.py └── views.py ├── core ├── __init__.py ├── api_router.py ├── asgi.py ├── base_crud.py ├── settings.py ├── urls.py ├── utils.py └── wsgi.py ├── manage.py ├── requirements.txt └── templates ├── Screenshot 2021-10-23 at 23.12.52.png ├── Screenshot 2021-10-23 at 23.13.05.png ├── Screenshot 2021-10-23 at 23.13.42.png ├── Screenshot 2021-10-23 at 23.13.51.png ├── accounts ├── base.html ├── email │ ├── base_message.txt │ ├── email_confirmation_message.html │ ├── email_confirmation_message.txt │ ├── email_confirmation_signup_message.html │ ├── email_confirmation_signup_message.txt │ ├── email_confirmation_signup_subject.txt │ ├── email_confirmation_subject.txt │ ├── password_reset_key_message.txt │ ├── password_reset_key_subject.txt │ └── verified_email_required.html ├── email_confirm.html ├── login.html ├── logout.html ├── password_change.html ├── password_reset.html ├── password_reset_done.html ├── password_reset_from_key.html ├── password_reset_from_key_done.html ├── password_set.html ├── registration │ └── password_reset_email.html ├── signup.html ├── snippets │ └── already_logged_in.html └── verification_sent.html ├── base.html └── pages ├── about.html └── home.html /.gitignore: -------------------------------------------------------------------------------- 1 | #custom 2 | venv/* 3 | db.sqlite3 4 | static/ 5 | 6 | venv/* 7 | db.sqlite3 8 | __pycache__/ 9 | */__pycache__/ 10 | */*/__pycache__/ 11 | */__pycache__/* 12 | */*/__pycache__/* 13 | 14 | # Byte-compiled / optimized / DLL files 15 | */__pycache__/ 16 | */*/__pycache__/ 17 | *.py[cod] 18 | *$py.class 19 | 20 | # C extensions 21 | *.so 22 | 23 | # Distribution / packaging 24 | .Python 25 | env/ 26 | build/ 27 | develop-eggs/ 28 | dist/ 29 | downloads/ 30 | eggs/ 31 | .eggs/ 32 | lib/ 33 | lib64/ 34 | parts/ 35 | sdist/ 36 | var/ 37 | wheels/ 38 | *.egg-info/ 39 | .installed.cfg 40 | *.egg 41 | 42 | # PyInstaller 43 | # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .coverage 54 | .coverage.* 55 | .cache 56 | nosetests.xml 57 | coverage.xml 58 | *.cover 59 | .hypothesis/ 60 | 61 | # Translations 62 | *.mo 63 | *.pot 64 | 65 | # Django stuff: 66 | *.log 67 | local_settings.py 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # celery beat schedule file 89 | celerybeat-schedule 90 | 91 | # SageMath parsed files 92 | *.sage.py 93 | 94 | # dotenv 95 | .env 96 | 97 | # virtualenv 98 | .venv 99 | venv/ 100 | ENV/ 101 | 102 | .DS_Store 103 | # Spyder project settings 104 | .spyderproject 105 | .spyproject 106 | 107 | # Rope project settings 108 | .ropeproject 109 | 110 | # mkdocs documentation 111 | /site 112 | 113 | # mypy 114 | .mypy_cache/ 115 | 116 | *.sqlite3 117 | 118 | # Ryffable Project Related 119 | 120 | 121 | # Elastic Beanstalk Files 122 | .elasticbeanstalk/* 123 | !.elasticbeanstalk/*.cfg.yml 124 | !.elasticbeanstalk/*.global.yml -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | Ensure your pull request adheres to the following guidelines: 4 | 5 | - Search previous suggestions before making a new one, as yours may be a duplicate. 6 | - Make an individual pull request for each suggestion. 7 | - Use [title-casing](http://titlecapitalization.com) (AP style). 8 | - Use the following format: `[Title Case Name](link) - Description.` 9 | > :information_source: [Articles](https://github.com/drmacsika/fastapi-django-combo) should use the `[Title Case Name](link)` format. 10 | - Keep descriptions short and simple, but descriptive. 11 | - Start the description with a capital and end with a full stop/period. 12 | > :information_source: You can use an emoji, only before the Title-Cased Description. 13 | - Check your spelling and grammar. 14 | - Make sure your text editor is set to remove trailing whitespace. 15 | - New categories or improvements to the existing categorization are welcome. 16 | - Pull requests should have a useful title. 17 | - The body of your commit message should contain a link to the repository. 18 | 19 | Thank you for your suggestion! 20 | 21 | ### Updating your PR 22 | 23 | A lot of times, making a PR adhere to the standards above can be difficult. If the maintainers notice anything that we'd like changed, we'll ask you to edit your PR before we merge it. There's no need to open a new PR, just edit the existing one. If you're not sure how to do that, make a research on the different ways you can update your PR so that we can merge it. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI and Django Combo 2 | This projects aims to combine FastAPI and Django to build a Production ready application capable of utilizing all of the features of both django and FastAPI. 3 | To demonstrate, I built a sample blog app, It can be adapted into any app. 4 | 5 | ## Table of Contents: 6 | - [Screenshots](#screenshots) 7 | - [Tools](#tools) 8 | - [Features](#features) 9 | - [Installation and Usage](#installation) 10 | - [Tutorial](#tutorial) 11 | - [Contributing](#contributing) 12 | - [Contact Info](#contact-info) 13 | 14 | ## Screenshots 15 | 16 | ![Django Admin Page 1](https://github.com/drmacsika/fastapi-django-combo/blob/master/templates/Screenshot%202021-10-23%20at%2023.12.52.png) 17 | 18 | ![Django Admin Page 2](https://github.com/drmacsika/fastapi-django-combo/blob/master/templates/Screenshot%202021-10-23%20at%2023.13.05.png) 19 | 20 | ![Fastapi Blog endpoints 1](https://github.com/drmacsika/fastapi-django-combo/blob/master/templates/Screenshot%202021-10-23%20at%2023.13.42.png) 21 | 22 | ![Fastapi Blog endpoints 2](https://github.com/drmacsika/fastapi-django-combo/blob/master/templates/Screenshot%202021-10-23%20at%2023.13.51.png) 23 | 24 | 25 | ## Tools 26 | 27 | - Django 28 | - Django Rest Framework (DRF) 29 | - FastAPI 30 | - Pydantic with custom validation 31 | - Django all-auth 32 | - JWT Authentication 33 | - CORS 34 | - Uvicorn and Gunicorn for Python web server 35 | 36 | ## Features 37 | 38 | - CRUD endpoints for blog posts and categories 39 | - CRUD endpoints for contacts 40 | - Asynchronous CRUD endpoints for user accounts 41 | - Endpoints for user authentication using DRF 42 | - Django settings file 43 | - Migrations using Django Migrations 44 | - Django ORM and Admin Page 45 | - JWT token authentication. 46 | 47 | ## Installation and Usage 48 | 49 | Use the package manager [pip](https://pip.pypa.io/en/stable/) for installation. 50 | 51 | ```bash 52 | - python -m venv venv 53 | - source venv/bin/activate 54 | - pip install -r requirements.txt 55 | - cd fastapi-django-combo 56 | - python manage.py makemigrations 57 | - python manage.py migrate 58 | - python manage.py collectstatic 59 | - python manage.py createsuperuser 60 | - uvicorn core.asgi:app --reload 61 | ``` 62 | 63 | ## Tutorial: 64 | - **Tutorial 1**: *[How to use FastAPI with Django ORM and Admin](https://nsikakimoh.com/learn/django-and-fastapi-combo-tutorials)* 65 | 66 | ## Contributing 67 | 68 | Pull requests and contributions are welcome. For major changes, please open an issue first to discuss what you would like to change. 69 | 70 | Ensure to follow the [guidelines](https://github.com/drmacsika/fastapi-django-combo/blob/master/CONTRIBUTING.md) and update tests as appropriate. 71 | 72 | ## Contact Info 73 | If you have any question or want to reach me directly, 74 | [Contact Nsikak Imoh](https://nsikakimoh.com). 75 | 76 | -------------------------------------------------------------------------------- /accounts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drmacsika/fastapi-django-combo/3025332d87a8da1ef13bfd7c6e27d23c9b4af685/accounts/__init__.py -------------------------------------------------------------------------------- /accounts/adapter.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from allauth.account.adapter import DefaultAccountAdapter 4 | from allauth.utils import build_absolute_uri 5 | from django.conf import settings 6 | from django.contrib.sites.shortcuts import get_current_site 7 | from django.core.mail import EmailMessage, EmailMultiAlternatives 8 | from django.http import HttpResponseRedirect 9 | from django.shortcuts import resolve_url 10 | from django.template import TemplateDoesNotExist 11 | from django.template.loader import render_to_string 12 | from django.urls import reverse_lazy 13 | from django.utils.translation import gettext_lazy as _ 14 | 15 | 16 | class MyAccountAdapter(DefaultAccountAdapter): 17 | def get_signup_redirect_url(self, request): 18 | return resolve_url(settings.SIGNUP_REDIRECT_URL) 19 | 20 | def get_email_confirmation_url(self, request, emailconfirmation): 21 | """Constructs the email confirmation (activation) url. 22 | Note that if you have architected your system such that email 23 | confirmations are sent outside of the request context `request` 24 | can be `None` here. 25 | """ 26 | url = reverse_lazy("accounts:confirm_email", 27 | args=[emailconfirmation.key]) 28 | ret = build_absolute_uri(request, url) 29 | return ret 30 | 31 | def send_confirmation_mail(self, request, emailconfirmation, signup): 32 | current_site = get_current_site(request) 33 | activate_url = self.get_email_confirmation_url( 34 | request, emailconfirmation) 35 | ctx = { 36 | "user": emailconfirmation.email_address.user, 37 | "activate_url": activate_url, 38 | "current_site": current_site, 39 | "key": emailconfirmation.key, 40 | } 41 | if signup: 42 | email_template = "accounts/email/email_confirmation_signup" 43 | else: 44 | email_template = "accounts/email/email_confirmation" 45 | self.send_mail( 46 | email_template, emailconfirmation.email_address.email, ctx) 47 | 48 | 49 | def send_token_confirmation_mail(self, request, emailconfirmation, signup): 50 | current_site = get_current_site(request) 51 | activate_url = self.get_email_confirmation_url( 52 | request, emailconfirmation) 53 | ctx = { 54 | "user": emailconfirmation.email_address.user, 55 | "activate_url": activate_url, 56 | "current_site": current_site, 57 | "key": emailconfirmation.key, 58 | } 59 | if signup: 60 | email_template = "accounts/email/email_confirmation_signup" 61 | else: 62 | email_template = "accounts/email/email_confirmation" 63 | self.send_mail( 64 | email_template, emailconfirmation.email_address.email, ctx) 65 | 66 | 67 | def render_mail(self, template_prefix, email, context): 68 | """ 69 | Renders an e-mail to `email`. `template_prefix` identifies the 70 | e-mail that is to be sent, e.g. "account/email/email_confirmation" 71 | """ 72 | to = [email] if isinstance(email, str) else email 73 | subject = render_to_string("{0}_subject.txt".format(template_prefix), context) 74 | # remove superfluous line breaks 75 | subject = " ".join(subject.splitlines()).strip() 76 | subject = self.format_email_subject(subject) 77 | 78 | from_email = self.get_from_email() 79 | 80 | bodies = {} 81 | for ext in ["html", "txt"]: 82 | try: 83 | template_name = "{0}_message.{1}".format(template_prefix, ext) 84 | bodies[ext] = render_to_string( 85 | template_name, 86 | context, 87 | self.request, 88 | ).strip() 89 | except TemplateDoesNotExist: 90 | if ext == "txt" and not bodies: 91 | # We need at least one body 92 | raise 93 | if "txt" in bodies: 94 | msg = EmailMultiAlternatives(subject, bodies["txt"], from_email, to) 95 | if "html" in bodies: 96 | msg.attach_alternative(bodies["html"], "text/html") 97 | else: 98 | msg = EmailMessage(subject, bodies["html"], from_email, to) 99 | msg.content_subtype = "html" # Main content is now text/html 100 | return msg 101 | 102 | def send_mail(self, template_prefix, email, context): 103 | msg = self.render_mail(template_prefix, email, context) 104 | msg.send() 105 | 106 | 107 | def respond_email_verification_sent(self, request, user): 108 | return HttpResponseRedirect(reverse_lazy("accounts:email_verification_sent")) 109 | -------------------------------------------------------------------------------- /accounts/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth import get_user_model 3 | from django.contrib.auth.admin import UserAdmin as BaseUserAdmin 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from .forms import UserAdminChangeForm, UserAdminCreationForm 7 | 8 | User = get_user_model() 9 | 10 | 11 | class UserAdmin(BaseUserAdmin): 12 | # The forms to add and change user instances 13 | add_form = UserAdminCreationForm 14 | form = UserAdminChangeForm 15 | # inlines = [ 16 | # ProfileInline, 17 | # ] 18 | 19 | fieldsets = ( 20 | # (None, {'fields': ('password',)}), # This simply shows the hashed password field 21 | (_('Personal info'), {'fields': ('name', 'email',)}), 22 | (_('Permissions'), { 23 | 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions',) 24 | }), 25 | (_('Important dates'), {'fields': ('last_login', 'date_joined',)}), 26 | ) 27 | # add_fieldsets is not a standard ModelAdmin attribute. UserAdmin 28 | # overrides get_fieldsets to use this attribute when creating a user. 29 | add_fieldsets = ( 30 | (None, { 31 | 'classes': ('wide',), 32 | 'fields': ('name', 'email', 'password1', 'password2')} 33 | ), 34 | ) 35 | list_display = ('id', 'email', 'name', 'is_active', 'is_staff') 36 | list_display_links = ['email'] 37 | list_filter = ('is_superuser', 'is_staff', 'is_active', 'groups') 38 | search_fields = ('email', 'name') 39 | ordering = ('email',) 40 | list_per_page = 10 41 | filter_horizontal = ('groups', 'user_permissions',) 42 | 43 | 44 | admin.site.register(User, UserAdmin) 45 | -------------------------------------------------------------------------------- /accounts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AccountsConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'accounts' 7 | -------------------------------------------------------------------------------- /accounts/forms.py: -------------------------------------------------------------------------------- 1 | from allauth.account.adapter import get_adapter 2 | from allauth.account.forms import EmailAwarePasswordResetTokenGenerator 3 | from allauth.account.models import EmailAddress 4 | from allauth.account.utils import user_pk_to_url_str 5 | from allauth.utils import build_absolute_uri 6 | from dj_rest_auth.forms import AllAuthPasswordResetForm 7 | from django import forms 8 | from django.contrib.auth import get_user_model, password_validation 9 | from django.contrib.auth.forms import (ReadOnlyPasswordHashField, 10 | UserCreationForm) 11 | from django.contrib.auth.models import User 12 | from django.contrib.sites.shortcuts import get_current_site 13 | from django.core.exceptions import ValidationError 14 | from django.urls import reverse 15 | from django.utils.translation import gettext_lazy as _ 16 | 17 | User = get_user_model() 18 | 19 | default_token_generator = EmailAwarePasswordResetTokenGenerator() 20 | 21 | 22 | def validate_password(self): 23 | if len(self) < 8: 24 | raise ValidationError( 25 | _('Password requires minimum 8 characters.'), 26 | code='invalid', 27 | ) 28 | if self.isalpha() or self.isdigit(): 29 | raise ValidationError( 30 | _('Your password must contain at least one number, one letter, and/or special character.'), 31 | code='invalid', 32 | ) 33 | 34 | 35 | def validate_fullname(self): 36 | fullname = self.split() 37 | if len(fullname) <= 1: 38 | raise ValidationError( 39 | _('Kindly enter more than one name, please.'), 40 | code='invalid', 41 | params={'value': self}, 42 | ) 43 | for x in fullname: 44 | if len(x) < 2: 45 | raise ValidationError( 46 | _('Please enter your name correctly.'), 47 | code='invalid', 48 | params={'value': self}, 49 | ) 50 | 51 | 52 | class UserForm(forms.Form): 53 | def __init__(self, user=None, *args, **kwargs): 54 | self.user = user 55 | super(UserForm, self).__init__(*args, **kwargs) 56 | 57 | 58 | class UserAdminCreationForm(UserCreationForm): 59 | 60 | password1 = forms.CharField( 61 | label='Password', 62 | widget=forms.PasswordInput, 63 | strip=True, 64 | validators=[validate_password], 65 | ) 66 | 67 | name = forms.CharField( 68 | label=_("Full Name"), 69 | widget=forms.TextInput, 70 | strip=True, 71 | validators=[validate_fullname], 72 | ) 73 | 74 | email = forms.EmailField(required=True) 75 | 76 | class Meta: 77 | model = User 78 | fields = ('name', 'email',) 79 | 80 | def clean_email(self): 81 | email = self.cleaned_data.get("email") 82 | if (User.objects.filter(email=email.casefold()).exists()): 83 | raise forms.ValidationError( 84 | _("This email address is already in use.")) 85 | return email 86 | 87 | def save(self, commit=True): 88 | user = super(UserAdminCreationForm, self).save(commit=False) 89 | user.set_password(self.cleaned_data["password1"]) 90 | user.email = self.cleaned_data["email"].lower() 91 | user.name = self.cleaned_data["name"].title() 92 | if commit: 93 | user.save() 94 | return user 95 | 96 | 97 | class UserAdminChangeForm(forms.ModelForm): 98 | password = ReadOnlyPasswordHashField( 99 | label=_("Password"), 100 | help_text=_( 101 | "Raw passwords are not stored, so there is no way to see this " 102 | "user's password, but you can change the password using " 103 | "this form." 104 | ), 105 | ) 106 | 107 | name = forms.CharField( 108 | label=_("Full Name"), 109 | widget=forms.TextInput, 110 | strip=True, 111 | validators=[validate_fullname], 112 | ) 113 | 114 | class Meta: 115 | model = User 116 | fields = '__all__' 117 | 118 | def __init__(self, *args, **kwargs): 119 | super().__init__(*args, **kwargs) 120 | password = self.fields.get('password') 121 | if password: 122 | password.help_text = password.help_text.format('../password/') 123 | user_permissions = self.fields.get('user_permissions') 124 | if user_permissions: 125 | user_permissions.queryset = user_permissions.queryset.select_related( 126 | 'content_type') 127 | 128 | def clean_password(self): 129 | # Regardless of what the user provides, return the initial value. 130 | # This is done here, rather than on the field, because the 131 | # field does not have access to the initial value 132 | return self.initial.get('password') 133 | 134 | def clean_name(self): 135 | name = self.cleaned_data["name"].title() 136 | return name 137 | 138 | # def clean_email(self): 139 | # if (User.objects.filter(email=self.cleaned_data.get("email").casefold()).exists()): 140 | # raise forms.ValidationError( 141 | # _("This email address is already in use.")) 142 | # email = self.cleaned_data["email"].lower() 143 | # return email 144 | 145 | def save(self, commit=True): 146 | user = super().save(commit=False) 147 | user.email = self.cleaned_data["email"].lower() 148 | user.name = self.cleaned_data["name"].title() 149 | if commit: 150 | user.save() 151 | return user 152 | 153 | 154 | class CustomSetPasswordForm(forms.Form): 155 | """ 156 | A form that lets a user change set their password without entering the old 157 | password 158 | """ 159 | error_messages = { 160 | 'password_mismatch': _("The two password fields didn't match."), 161 | } 162 | new_password1 = forms.CharField( 163 | label=_("New password"), 164 | widget=forms.PasswordInput, 165 | strip=False, 166 | help_text=password_validation.password_validators_help_text_html(), 167 | ) 168 | new_password2 = forms.CharField( 169 | label=_("New password confirmation"), 170 | strip=False, 171 | widget=forms.PasswordInput, 172 | ) 173 | 174 | def __init__(self, user, *args, **kwargs): 175 | self.user = user 176 | super().__init__(*args, **kwargs) 177 | 178 | def clean_new_password2(self): 179 | password1 = self.cleaned_data.get('new_password1') 180 | password2 = self.cleaned_data.get('new_password2') 181 | if password1 and password2: 182 | if password1 != password2: 183 | raise forms.ValidationError( 184 | self.error_messages['password_mismatch'], 185 | code='password_mismatch', 186 | ) 187 | password_validation.validate_password(password2, self.user) 188 | return password2 189 | 190 | def save(self, commit=True): 191 | password = self.cleaned_data["new_password1"] 192 | self.user.set_password(password) 193 | if commit: 194 | self.user.save() 195 | return self.user 196 | 197 | 198 | class EmailConfirmationForm(UserForm): 199 | 200 | email = forms.EmailField( 201 | label=_("E-mail"), 202 | required=True, 203 | widget=forms.TextInput( 204 | attrs={"type": "email", "placeholder": _("E-mail address")} 205 | ), 206 | ) 207 | 208 | def clean_email(self): 209 | value = self.cleaned_data["email"] 210 | value = self.clean_email(value) 211 | return value 212 | 213 | def save(self, request): 214 | return EmailAddress.objects.add_email( 215 | request, self.user, self.cleaned_data["email"], confirm=True 216 | ) 217 | 218 | class CustomPasswordResetForm(AllAuthPasswordResetForm): 219 | """ 220 | We inherit the Original class of the allauth package to specify the 221 | custom reset password template by overriding the save method since 222 | there's no adapter to specify the default email template. 223 | """ 224 | def save(self, request, **kwargs): 225 | current_site = get_current_site(request) 226 | email = self.cleaned_data['email'] 227 | token_generator = kwargs.get('token_generator', default_token_generator) 228 | 229 | for user in self.users: 230 | 231 | temp_key = token_generator.make_token(user) 232 | 233 | path = reverse( 234 | 'password_reset_confirm', 235 | args=[user_pk_to_url_str(user), temp_key], 236 | ) 237 | url = build_absolute_uri(request, path) 238 | 239 | context = { 240 | 'current_site': current_site, 241 | 'user': user, 242 | 'password_reset_url': url, 243 | 'request': request, 244 | } 245 | get_adapter(request).send_mail( 246 | 'accounts/email/password_reset_key', email, context 247 | ) 248 | return self.cleaned_data['email'] 249 | 250 | 251 | -------------------------------------------------------------------------------- /accounts/managers.py: -------------------------------------------------------------------------------- 1 | from core.utils import get_first_name, get_last_name 2 | from django.contrib.auth.models import BaseUserManager 3 | from django.core.exceptions import ValidationError 4 | from django.db import models 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | 8 | class UserQuerySet(models.QuerySet): 9 | pass 10 | 11 | 12 | class UserManager(BaseUserManager): 13 | use_in_migrations = True 14 | 15 | def get_queryset(self): 16 | return UserQuerySet(self.model, using=self._db) 17 | 18 | def _create_user(self, name, email, password, **extra_fields): 19 | """ 20 | Create and save a user with the given name, email, and password. 21 | """ 22 | if not email: 23 | raise ValueError(_("Users must have an email address")) 24 | 25 | fullname = name.split() 26 | if len(fullname) <= 1: 27 | raise ValidationError( 28 | _('Kindly enter more than one name.'), 29 | code='invalid', 30 | params={'value': self}, 31 | ) 32 | for x in fullname: 33 | if len(x) < 2: 34 | raise ValidationError( 35 | _('Kindly give us your full name.'), 36 | code='invalid', 37 | params={'value': self}, 38 | ) 39 | email = self.normalize_email(email) 40 | name = ' '.join(map(str, fullname)) 41 | user = self.model(name=name, email=email, **extra_fields) 42 | user.set_password(password) 43 | user.save(using=self._db) 44 | return user 45 | 46 | def create_user(self, name, email=None, password=None, **extra_fields): 47 | extra_fields.setdefault('is_staff', False) 48 | extra_fields.setdefault('is_superuser', False) 49 | return self._create_user(name, email, password, **extra_fields) 50 | 51 | def create_staff(self, name, email, password, **extra_fields): 52 | extra_fields.setdefault('is_staff', True) 53 | extra_fields.setdefault('is_superuser', False) 54 | 55 | if extra_fields.get('is_staff') is not True: 56 | raise ValueError('Staff must have is_staff=True.') 57 | 58 | return self._create_user(name, email, password, **extra_fields) 59 | 60 | def create_superuser(self, name, email, password, **extra_fields): 61 | extra_fields.setdefault('is_staff', True) 62 | extra_fields.setdefault('is_superuser', True) 63 | 64 | if extra_fields.get('is_staff') is not True: 65 | raise ValueError('Admin must have is_staff=True.') 66 | if extra_fields.get('is_superuser') is not True: 67 | raise ValueError('Admin must have is_superuser=True.') 68 | 69 | return self._create_user(name, email, password, **extra_fields) 70 | 71 | def get_full_name(self): 72 | return self.name 73 | 74 | def get_short_name(self): 75 | return get_first_name(self.name) 76 | 77 | def get_first_name(self): 78 | return get_first_name(self.name) 79 | 80 | def get_last_name(self): 81 | return get_last_name(self.name) 82 | -------------------------------------------------------------------------------- /accounts/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-10-21 20:38 2 | 3 | import accounts.managers 4 | from django.db import migrations, models 5 | import django.utils.timezone 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ('auth', '0012_alter_user_first_name_max_length'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='CustomUser', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('password', models.CharField(max_length=128, verbose_name='password')), 22 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 23 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 24 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 25 | ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), 26 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 27 | ('name', models.CharField(default=False, max_length=150)), 28 | ('email', models.EmailField(max_length=255, unique=True)), 29 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), 30 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), 31 | ], 32 | options={ 33 | 'verbose_name': 'user', 34 | 'verbose_name_plural': 'users', 35 | 'abstract': False, 36 | }, 37 | managers=[ 38 | ('objects', accounts.managers.UserManager()), 39 | ], 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /accounts/migrations/0002_alter_customuser_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-10-23 01:27 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('accounts', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='customuser', 15 | name='name', 16 | field=models.CharField(max_length=150), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /accounts/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drmacsika/fastapi-django-combo/3025332d87a8da1ef13bfd7c6e27d23c9b4af685/accounts/migrations/__init__.py -------------------------------------------------------------------------------- /accounts/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractUser 2 | from django.db import models 3 | 4 | from accounts.managers import UserManager 5 | 6 | 7 | class CustomUser(AbstractUser): 8 | username = None 9 | first_name = None 10 | last_name = None 11 | password2 = None 12 | 13 | name = models.CharField(max_length=150) 14 | email = models.EmailField(max_length=255, unique=True) 15 | 16 | objects = UserManager() 17 | 18 | USERNAME_FIELD = 'email' 19 | REQUIRED_FIELDS = ['name'] 20 | 21 | def __str__(self): 22 | return self.email 23 | -------------------------------------------------------------------------------- /accounts/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pydantic import BaseModel, EmailStr 4 | 5 | 6 | class UserOut(BaseModel): 7 | """ 8 | Base fields for user response. 9 | """ 10 | # id: int 11 | name: str 12 | # email: EmailStr 13 | # date_joined: datetime 14 | # last_login: datetime 15 | # is_staff: bool = False 16 | # is_active: bool = False 17 | # is_superuser: bool = False 18 | 19 | class Config: 20 | orm_mode = True 21 | 22 | -------------------------------------------------------------------------------- /accounts/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.auth.forms import SetPasswordForm 3 | from django.core.exceptions import ValidationError as DjangoValidationError 4 | from django.urls import exceptions as url_exceptions 5 | from django.utils.translation import gettext_lazy as _ 6 | from rest_framework import exceptions, serializers 7 | 8 | try: 9 | from allauth.account import app_settings as allauth_settings 10 | from allauth.account.adapter import get_adapter 11 | from allauth.account.utils import setup_user_email 12 | except ImportError: 13 | raise ImportError('allauth needs to be added to INSTALLED_APPS.') 14 | 15 | from dj_rest_auth.registration.serializers import RegisterSerializer 16 | from dj_rest_auth.serializers import LoginSerializer, PasswordResetSerializer 17 | from django.db import transaction 18 | 19 | from .forms import CustomPasswordResetForm 20 | 21 | User = get_user_model() 22 | 23 | 24 | class CustomLoginSerializer(LoginSerializer): 25 | 26 | def get_auth_user(self, username, email, password): 27 | try: 28 | return self.get_auth_user_using_allauth(username, email, password) 29 | except url_exceptions.NoReverseMatch: 30 | msg = _('The email or password you provided is incorrect.') 31 | raise exceptions.ValidationError(msg) 32 | 33 | 34 | @staticmethod 35 | def validate_auth_user_status(user): 36 | if not user.is_active: 37 | msg = _( 38 | 'This account is currently disabled. Please contact us for more info.') 39 | raise exceptions.ValidationError(msg) 40 | 41 | @staticmethod 42 | def validate_email_verification_status(user): 43 | email_address = user.emailaddress_set.get(email=user.email) 44 | if not email_address.verified: 45 | resend_link = "click here." 46 | err_msg = ( 47 | f'This email address is not verified. To verify your email {resend_link}') 48 | raise serializers.ValidationError(_(err_msg)) 49 | 50 | def validate(self, attrs): 51 | username = attrs.get('username') 52 | email = attrs.get('email') 53 | password = attrs.get('password') 54 | user = self.get_auth_user(username, email, password) 55 | 56 | if not user: 57 | msg = _('The email or password you provided is incorrect.') 58 | raise exceptions.ValidationError(msg) 59 | 60 | # Did we get back an active user? 61 | self.validate_auth_user_status(user) 62 | 63 | # If required, is the email verified? 64 | self.validate_email_verification_status(user) 65 | 66 | attrs['user'] = user 67 | return attrs 68 | 69 | 70 | class CustomRegisterSerializer(RegisterSerializer): 71 | name = serializers.CharField( 72 | required=True, allow_blank=False, max_length=100) 73 | email = serializers.EmailField(required=allauth_settings.EMAIL_REQUIRED) 74 | password1 = serializers.CharField(write_only=True) 75 | password2 = serializers.CharField(required=False) 76 | 77 | def validate_fullname(self, fullname): 78 | fullname = fullname.split() 79 | if len(fullname) <= 1: 80 | raise serializers.ValidationError( 81 | _('Kindly enter more than one name.'),) 82 | for x in fullname: 83 | if len(x) < 2: 84 | raise serializers.ValidationError( 85 | _('Kindly give us your full name.'),) 86 | return fullname 87 | 88 | def get_cleaned_data(self): 89 | return { 90 | 'name': self.validated_data.get('name', ''), 91 | 'password1': self.validated_data.get('password1', ''), 92 | 'email': self.validated_data.get('email', ''), 93 | } 94 | 95 | def validate(self, data): 96 | fullname = data['name'].split() 97 | if len(fullname) <= 1: 98 | raise serializers.ValidationError( 99 | _('Kindly enter more than one name.'),) 100 | for x in fullname: 101 | if len(x) < 2: 102 | raise serializers.ValidationError( 103 | _('Kindly give us your full name.'),) 104 | return data 105 | 106 | @transaction.atomic 107 | def save(self, request): 108 | # user = super().save(*args, **kwargs) 109 | adapter = get_adapter() 110 | user = adapter.new_user(request) 111 | self.cleaned_data = self.get_cleaned_data() 112 | user = adapter.save_user(request, user, self, commit=False) 113 | try: 114 | adapter.clean_password(self.cleaned_data['password1'], user=user) 115 | except DjangoValidationError as exc: 116 | raise serializers.ValidationError( 117 | detail=serializers.as_serializer_error(exc) 118 | ) 119 | user.name = self.cleaned_data["name"].title() 120 | user.save() 121 | setup_user_email(request, user, []) 122 | return user 123 | 124 | 125 | class CustomUserDetailsSerializer(serializers.ModelSerializer): 126 | 127 | class Meta: 128 | model = User 129 | fields = ( 130 | 'name', 131 | ) 132 | read_only_fields = ('email',) 133 | 134 | 135 | class CustomPasswordSetSerializer(serializers.Serializer): 136 | new_password1 = serializers.CharField(max_length=128) 137 | new_password2 = serializers.CharField(max_length=128) 138 | 139 | set_password_form_class = SetPasswordForm 140 | set_password_form = None 141 | 142 | def __init__(self, *args, **kwargs): 143 | 144 | super().__init__(*args, **kwargs) 145 | 146 | self.request = self.context.get('request') 147 | self.user = getattr(self.request, 'user', None) 148 | if self.request.user.has_usable_password(): 149 | # return HttpResponseRedirect(reverse("account_change_password")) 150 | change_password_link = "click here" 151 | err_msg = ( 152 | f'You\'ve already set a password for this account. To change your password {change_password_link}') 153 | raise serializers.ValidationError(_(err_msg)) 154 | 155 | def validate_old_password(self, value): 156 | pass 157 | 158 | def validate(self, attrs): 159 | self.set_password_form = self.set_password_form_class( 160 | user=self.user, data=attrs, 161 | ) 162 | if not self.set_password_form.is_valid(): 163 | raise serializers.ValidationError(self.set_password_form.errors) 164 | return attrs 165 | 166 | def save(self): 167 | self.set_password_form.save() 168 | from django.contrib.auth import update_session_auth_hash 169 | update_session_auth_hash(self.request, self.user) 170 | 171 | 172 | class CustomUserDetailsSerializer(serializers.ModelSerializer): 173 | 174 | class Meta: 175 | model = User 176 | fields = ( 177 | 'name', 178 | ) 179 | read_only_fields = ('email',) 180 | 181 | 182 | class CustomEmailConfirmationSerializer(serializers.Serializer): 183 | email = serializers.EmailField(required=True) 184 | 185 | 186 | class CustomPasswordResetSerializer(PasswordResetSerializer): 187 | """ 188 | This inherits from dj_rest_auth to solely for custom email 189 | for password reset 190 | """ 191 | @property 192 | def password_reset_form_class(self): 193 | return CustomPasswordResetForm 194 | -------------------------------------------------------------------------------- /accounts/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /accounts/urls.py: -------------------------------------------------------------------------------- 1 | from core.settings import DRF_V1_STR 2 | from dj_rest_auth.registration.views import ConfirmEmailView, VerifyEmailView 3 | from django.urls import path 4 | from django.urls.conf import include, path 5 | 6 | from .views import (CustomEmailConfirmationView, CustomLoginView, 7 | CustomPasswordResetView, CustomPasswordSetView, 8 | CustomRegisterView) 9 | 10 | app_name = "accounts" 11 | 12 | urlpatterns = [ 13 | path("accounts/", include('dj_rest_auth.urls')), 14 | # The confirm_email path below should always be placed 15 | # above the signup view otherwise there will be a template error 16 | path("accounts/confirm-email//", 17 | ConfirmEmailView.as_view(), name='confirm_email', 18 | ), 19 | path("accounts/signin/", 20 | CustomLoginView.as_view(), name="login"), 21 | path("accounts/password/reset/", CustomPasswordResetView.as_view(), 22 | name='password_reset'), 23 | path("accounts/password/set/", 24 | CustomPasswordSetView.as_view(), name="set_password"), 25 | path(f"accounts/signup/", CustomRegisterView.as_view(), 26 | name='signup'), 27 | path("accounts/confirm-email/", VerifyEmailView.as_view(), 28 | name='email_verification_sent'), 29 | path("accounts/resend-email/", 30 | CustomEmailConfirmationView.as_view(), name="resend_email"), 31 | 32 | ] 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /accounts/views.py: -------------------------------------------------------------------------------- 1 | from allauth.account.models import EmailAddress 2 | from allauth.account.utils import send_email_confirmation 3 | from dj_rest_auth.registration.views import RegisterView 4 | from dj_rest_auth.views import (LoginView, PasswordResetConfirmView, 5 | PasswordResetView) 6 | from django.contrib.auth import get_user_model 7 | from django.utils.decorators import method_decorator 8 | from django.utils.translation import gettext_lazy as _ 9 | from django.views.decorators.debug import sensitive_post_parameters 10 | from rest_framework import status 11 | from rest_framework.generics import GenericAPIView 12 | from rest_framework.permissions import AllowAny, IsAuthenticated 13 | from rest_framework.response import Response 14 | 15 | from accounts.serializers import (CustomEmailConfirmationSerializer, 16 | CustomLoginSerializer, 17 | CustomPasswordResetSerializer, 18 | CustomPasswordSetSerializer, 19 | CustomRegisterSerializer) 20 | 21 | User = get_user_model() 22 | 23 | sensitive_post_parameters_m = method_decorator( 24 | sensitive_post_parameters( 25 | 'password', 'old_password', 'new_password1', 'new_password2', 26 | ), 27 | ) 28 | 29 | 30 | class CustomLoginView(LoginView): 31 | serializer_class = CustomLoginSerializer 32 | 33 | 34 | class CustomRegisterView(RegisterView): 35 | serializer_class = CustomRegisterSerializer 36 | 37 | 38 | class CustomPasswordResetConfirmView(PasswordResetConfirmView): 39 | email_template_name = 'accounts/registration/password_reset_email.html' 40 | 41 | 42 | class CustomPasswordResetView(PasswordResetView): 43 | serializer_class = CustomPasswordResetSerializer 44 | 45 | class CustomPasswordSetView(GenericAPIView): 46 | serializer_class = CustomPasswordSetSerializer 47 | permission_classes = (IsAuthenticated,) 48 | 49 | @sensitive_post_parameters_m 50 | def dispatch(self, *args, **kwargs): 51 | return super().dispatch(*args, **kwargs) 52 | 53 | def post(self, request, *args, **kwargs): 54 | serializer = self.get_serializer(data=request.data) 55 | serializer.is_valid(raise_exception=True) 56 | serializer.save() 57 | return Response({'detail': _('New password has been saved.')}) 58 | 59 | 60 | class CustomEmailConfirmationView(GenericAPIView): 61 | permission_classes = (AllowAny,) 62 | serializer_class = CustomEmailConfirmationSerializer 63 | 64 | def post(self, request, *args, **kwargs): 65 | serializer = self.get_serializer(data=request.data) 66 | serializer.is_valid(raise_exception=True) 67 | 68 | try: 69 | email = EmailAddress.objects.get(**serializer.validated_data) 70 | except: 71 | return Response({'email': _("Account does not exist")}, status=status.HTTP_400_BAD_REQUEST) 72 | 73 | if email.verified: 74 | return Response({'email': _("Account is already verified")}, status=status.HTTP_400_BAD_REQUEST) 75 | try: 76 | user = User.objects.get(email=email) 77 | send_email_confirmation(request, user=user) 78 | return Response({'email': _('Email confirmation sent!')}, status=status.HTTP_200_OK) 79 | except: 80 | return Response({'email': _('Something went wrong while sending you an email.')}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) 81 | -------------------------------------------------------------------------------- /blog/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drmacsika/fastapi-django-combo/3025332d87a8da1ef13bfd7c6e27d23c9b4af685/blog/__init__.py -------------------------------------------------------------------------------- /blog/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from blog.models import Category, Post 4 | 5 | 6 | class BlogCategoryAdmin(admin.ModelAdmin): 7 | list_display: list = ['title', 'active'] 8 | list_display_links: list = ['title'] 9 | list_filter: list = ['updated', 'active'] 10 | search_fields: list = ['title'] 11 | list_editable: list = ['active'] 12 | list_per_page: list = 10 13 | ordering: tuple = ('-id',) 14 | 15 | 16 | admin.site.register(Category, BlogCategoryAdmin) 17 | 18 | 19 | class PostModelAdmin(admin.ModelAdmin): 20 | list_display: list = ['title', 'updated', 'created_on', 'publish', 'draft'] 21 | list_display_links: list = ['title'] 22 | list_filter: list = ['updated', 'created_on', 'publish'] 23 | list_editable: list = ['draft'] 24 | search_fields: list = ['title', 'content'] 25 | list_per_page: int = 10 26 | ordering: tuple = ('-id',) 27 | 28 | class Meta: 29 | model: Post = Post 30 | 31 | admin.site.register(Post, PostModelAdmin) 32 | -------------------------------------------------------------------------------- /blog/api_crud.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, List, Optional, Type, TypeVar 2 | from unicodedata import category 3 | 4 | from core.base_crud import SLUGTYPE, BaseCRUD 5 | from core.utils import unique_slug_generator 6 | from django.core.exceptions import ObjectDoesNotExist 7 | from django.db.models import Model, Prefetch, query 8 | from fastapi import Depends, HTTPException 9 | from fastapi.encoders import jsonable_encoder 10 | 11 | from blog.models import Category, Post 12 | from blog.schemas import CreateCategory, CreatePost, UpdateCategory, UpdatePost 13 | 14 | 15 | class PostCRUD(BaseCRUD[Post, CreatePost, UpdatePost, SLUGTYPE]): 16 | """ 17 | CRUD Operation for blog posts 18 | """ 19 | 20 | def get(self, slug: SLUGTYPE) -> Optional[Post]: 21 | """ 22 | Get single blog post. 23 | """ 24 | try: 25 | query = Post.objects.select_related("user", "category").get(slug=slug) 26 | return query 27 | except ObjectDoesNotExist: 28 | raise HTTPException(status_code=404, detail="This post does not exists.") 29 | 30 | def get_multiple(self, limit:int = 100, offset: int = 0) -> List[Post]: 31 | """ 32 | Get multiple posts using a query limit and offset flag. 33 | """ 34 | query = Post.objects.select_related("user", "category").all()[offset:offset+limit] 35 | if not query: 36 | raise HTTPException(status_code=404, detail="There are no posts.") 37 | return list(query) 38 | 39 | def get_posts_by_category(self, slug: SLUGTYPE) -> List[Post]: 40 | """ 41 | Get all posts belonging to a particular category. 42 | """ 43 | query_category = Category.objects.filter(slug=slug) 44 | if not query_category: 45 | raise HTTPException(status_code=404, detail="This category does not exist.") 46 | query = Post.objects.filter(category__slug=slug).select_related("user").all() 47 | return list(query) 48 | 49 | def create(self, obj_in: CreatePost) -> Post: 50 | """ 51 | Create an post. 52 | """ 53 | slug = unique_slug_generator(obj_in.title) 54 | post = Post.objects.filter(slug=slug) 55 | if not post: 56 | slug = unique_slug_generator(obj_in.title, new_slug=True) 57 | obj_in = jsonable_encoder(obj_in) 58 | query = Post.objects.create(**obj_in) 59 | return query 60 | 61 | def update(self, obj_in: UpdatePost, slug: SLUGTYPE) -> Post: 62 | """ 63 | Update an item. 64 | """ 65 | self.get(slug=slug) 66 | if not isinstance(obj_in, list): 67 | obj_in = jsonable_encoder(obj_in) 68 | return Post.objects.filter(slug=slug).update(**obj_in) 69 | 70 | def delete(self, slug: SLUGTYPE) -> Post: 71 | """Delete an item.""" 72 | self.model.objects.filter(slug=slug).delete() 73 | return {"detail": "Successfully deleted!"} 74 | 75 | 76 | class CategoryCRUD(BaseCRUD[Category, CreateCategory, UpdateCategory, SLUGTYPE]): 77 | """ 78 | CRUD Operation for blog categories. 79 | """ 80 | 81 | def get(self, slug: SLUGTYPE) -> Optional[Category]: 82 | """ 83 | Get a single category. 84 | """ 85 | try: 86 | query = Category.objects.get(slug=slug) 87 | return query 88 | except ObjectDoesNotExist: 89 | raise HTTPException(status_code=404, detail="This post does not exists.") 90 | 91 | def get_multiple(self, limit:int = 100, offset: int = 0) -> List[Category]: 92 | """ 93 | Get multiple categories using a query limiting flag. 94 | """ 95 | query = Category.objects.all()[offset:offset+limit] 96 | if not query: 97 | raise HTTPException(status_code=404, detail="There are no posts.") 98 | return list(query) 99 | 100 | def create(self, obj_in: CreateCategory) -> Category: 101 | """ 102 | Create a category. 103 | """ 104 | slug = unique_slug_generator(obj_in.title) 105 | category = Category.objects.filter(slug=slug) 106 | if category: 107 | raise HTTPException(status_code=404, detail="Category exists already.") 108 | obj_in = jsonable_encoder(obj_in) 109 | query = Category.objects.create(**obj_in) 110 | return query 111 | 112 | def update(self, obj_in: UpdateCategory, slug: SLUGTYPE) -> Category: 113 | """ 114 | Update a category. 115 | """ 116 | if not isinstance(obj_in, list): 117 | obj_in = jsonable_encoder(obj_in) 118 | return self.model.objects.filter(slug=slug).update(**obj_in) 119 | 120 | def delete(self, slug: SLUGTYPE) -> Post: 121 | """Delete a category.""" 122 | Post.objects.filter(slug=slug).delete() 123 | return {"detail": "Successfully deleted!"} 124 | 125 | 126 | post = PostCRUD(Post) 127 | category = CategoryCRUD(Category) 128 | -------------------------------------------------------------------------------- /blog/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BlogConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'blog' 7 | -------------------------------------------------------------------------------- /blog/endpoints.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | 3 | from fastapi import APIRouter 4 | 5 | from blog.api_crud import category, post 6 | from blog.schemas import (AllPostList, CategoryOut, CreateCategory, CreatePost, 7 | PostByCategoryList, SinglePost, UpdateCategory, 8 | UpdatePost) 9 | 10 | router = APIRouter() 11 | 12 | 13 | @router.get("/posts/", response_model=List[AllPostList]) 14 | def get_multiple_posts(offset: int = 0, limit: int = 10) -> Any: 15 | """ 16 | Endpoint to get multiple posts based on offset and limit values. 17 | """ 18 | return post.get_multiple(offset=offset, limit=limit) 19 | 20 | 21 | @router.post("/posts/", status_code=201, response_model=SinglePost) 22 | def create_post(request: CreatePost) -> Any: 23 | """ 24 | Endpoint to create single post. 25 | """ 26 | return post.create(obj_in=request) 27 | 28 | 29 | @router.post("/tags/", status_code=201, response_model=CategoryOut) 30 | def create_category(request: CreateCategory) -> Any: 31 | """ 32 | Endpoint to create single category. 33 | """ 34 | return category.create(obj_in=request) 35 | 36 | 37 | @router.get("/tags/", response_model=List[CategoryOut]) 38 | def get_multiple_categories(offset: int = 0, limit: int = 10) -> Any: 39 | """ 40 | Get multiple categories. 41 | """ 42 | query = category.get_multiple(limit=limit, offset=offset) 43 | return list(query) 44 | 45 | 46 | @router.get("/posts/{slug}/", response_model=SinglePost) 47 | def get_post(slug: str) -> Any: 48 | """ 49 | Get single blog post. 50 | """ 51 | return post.get(slug=slug) 52 | 53 | 54 | @router.get("/tags/{slug}/", response_model=List[PostByCategoryList]) 55 | def get_posts_by_category(slug: str) -> Any: 56 | """ 57 | Get all posts belonging to a particular category 58 | """ 59 | query = post.get_posts_by_category(slug=slug) 60 | return list(query) 61 | 62 | 63 | @router.put("/posts/{slug}/", response_model=SinglePost) 64 | def update_post(slug: str, request: UpdatePost) -> Any: 65 | """ 66 | Update a single blog post. 67 | """ 68 | return post.update(slug=slug, obj_in=request) 69 | 70 | 71 | @router.put("/tags/{slug}/", response_model=CategoryOut) 72 | def update_category(slug: str, request: UpdateCategory) -> Any: 73 | """ 74 | Update a single blog category. 75 | """ 76 | return category.update(slug=slug, obj_in=request) 77 | 78 | 79 | @router.delete("/posts/{slug}/") 80 | def delete_post(slug: str) -> Any: 81 | """ 82 | Delete a single blog post. 83 | """ 84 | return post.delete(slug=slug) 85 | 86 | 87 | @router.delete("/tags/{slug}/", response_model=CategoryOut) 88 | def delete_category(slug: str) -> Any: 89 | """ 90 | Delete a single blog category. 91 | """ 92 | return category.delete(slug=slug) 93 | 94 | -------------------------------------------------------------------------------- /blog/managers.py: -------------------------------------------------------------------------------- 1 | from datetime import timezone 2 | 3 | from django.db import models 4 | 5 | 6 | class PostQuerySet(models.query.QuerySet): 7 | def active(self, *args, **kwargs): 8 | return super(PostQuerySet, self).filter(draft=False).filter( 9 | publish__lte=timezone.now()) 10 | 11 | def search(self, query): 12 | lookups = (models.Q(title__icontains=query) | 13 | models.Q(content__icontains=query) | 14 | models.Q(user__full_name__icontains=query) 15 | ) 16 | return self.filter(lookups).distinct() 17 | 18 | 19 | class CategoryManager(models.Manager): 20 | def all(self): 21 | return self.get_queryset() 22 | 23 | def active(self, *args, **kwargs): 24 | return super(CategoryManager, self).filter(active=True) 25 | 26 | 27 | class PostManager(models.Manager): 28 | def get_queryset(self): 29 | return PostQuerySet(self.model, using=self._db) 30 | 31 | def all(self): 32 | return self.get_queryset() 33 | 34 | def active(self, *args, **kwargs): 35 | return super(PostManager, self).filter(draft=False).filter( 36 | publish__lte=timezone.now()) 37 | 38 | def full_search(self, query): 39 | return self.get_queryset().search(query) 40 | 41 | def filtered_search(self, query): 42 | return self.get_queryset().active().search(query) 43 | -------------------------------------------------------------------------------- /blog/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-10-21 20:38 2 | 3 | import blog.models 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Category', 20 | fields=[ 21 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('title', models.CharField(max_length=200)), 23 | ('description', models.CharField(blank=True, max_length=200, null=True)), 24 | ('slug', models.SlugField(blank=True, unique=True)), 25 | ('active', models.BooleanField(default=False)), 26 | ('updated', models.DateTimeField(auto_now=True)), 27 | ('created_on', models.DateTimeField(auto_now_add=True)), 28 | ], 29 | options={ 30 | 'verbose_name_plural': 'categories', 31 | }, 32 | ), 33 | migrations.CreateModel( 34 | name='Post', 35 | fields=[ 36 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 37 | ('title', models.CharField(max_length=250)), 38 | ('description', models.TextField(blank=True, null=True)), 39 | ('content', models.TextField(default='Create a post.')), 40 | ('slug', models.SlugField(blank=True, unique=True)), 41 | ('thumbnail', models.FileField(blank=True, max_length=1000, null=True, upload_to=blog.models.upload_image_path)), 42 | ('draft', models.BooleanField(default=False)), 43 | ('publish', models.DateField()), 44 | ('read_time', models.IntegerField(default=0)), 45 | ('view_count', models.PositiveIntegerField(default=0)), 46 | ('updated', models.DateTimeField(auto_now=True)), 47 | ('created_on', models.DateTimeField(auto_now_add=True)), 48 | ('category', models.ManyToManyField(to='blog.Category')), 49 | ('user', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='posts', to=settings.AUTH_USER_MODEL)), 50 | ], 51 | options={ 52 | 'verbose_name': 'post', 53 | 'verbose_name_plural': 'posts', 54 | 'ordering': ['-created_on', '-title'], 55 | }, 56 | ), 57 | migrations.AddConstraint( 58 | model_name='category', 59 | constraint=models.UniqueConstraint(fields=('title', 'slug'), name='unique_blog_category'), 60 | ), 61 | ] 62 | -------------------------------------------------------------------------------- /blog/migrations/0002_auto_20211021_2321.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-10-21 23:21 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('blog', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='category', 16 | name='parent', 17 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='blog.category'), 18 | ), 19 | migrations.RemoveField( 20 | model_name='post', 21 | name='category', 22 | ), 23 | migrations.AddField( 24 | model_name='post', 25 | name='category', 26 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='post_category', to='blog.category'), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /blog/migrations/0003_auto_20211022_1338.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-10-22 13:38 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('blog', '0002_auto_20211021_2321'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='category', 18 | name='parent', 19 | field=models.ForeignKey(default=1, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tags', to='blog.category'), 20 | ), 21 | migrations.AlterField( 22 | model_name='post', 23 | name='category', 24 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='post_category', to='blog.category'), 25 | ), 26 | migrations.AlterField( 27 | model_name='post', 28 | name='user', 29 | field=models.ForeignKey(default=1, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='posts', to=settings.AUTH_USER_MODEL), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /blog/migrations/0004_auto_20211022_1403.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-10-22 14:03 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('blog', '0003_auto_20211022_1338'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='category', 18 | name='parent', 19 | field=models.ForeignKey(default=1, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tags', to='blog.category'), 20 | ), 21 | migrations.AlterField( 22 | model_name='post', 23 | name='category', 24 | field=models.ForeignKey(blank=True, default=1, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='post_category', to='blog.category'), 25 | ), 26 | migrations.AlterField( 27 | model_name='post', 28 | name='user', 29 | field=models.ForeignKey(default=1, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='posts', to=settings.AUTH_USER_MODEL), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /blog/migrations/0005_alter_post_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-10-23 00:05 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('blog', '0004_auto_20211022_1403'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='post', 15 | options={'ordering': ['-publish', 'title'], 'verbose_name': 'post', 'verbose_name_plural': 'posts'}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /blog/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drmacsika/fastapi-django-combo/3025332d87a8da1ef13bfd7c6e27d23c9b4af685/blog/migrations/__init__.py -------------------------------------------------------------------------------- /blog/models.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | from pathlib import Path 3 | from typing import Any, Callable, List, Union 4 | 5 | from django.conf import settings 6 | from django.db import models 7 | from pydantic import AnyUrl 8 | 9 | from blog.managers import CategoryManager, PostManager 10 | 11 | 12 | def upload_image_path( 13 | instance: Any, 14 | filename: str) -> Union[str, Callable, Path]: 15 | """ 16 | Set up the default thumbnail upload path. 17 | """ 18 | ext = filename.split('.')[-1] 19 | filename = "%s.%s" %(instance.slug, ext) 20 | return "blog_post_thumbnails/%s/%s" %(instance.slug, filename) 21 | 22 | 23 | class Category(models.Model): 24 | """ 25 | Model for blog tags. 26 | """ 27 | title: str = models.CharField(max_length=200) 28 | description: str = models.CharField(max_length=200, blank=True, null=True) 29 | slug: str = models.SlugField(unique=True, blank=True) 30 | parent: Union[str, int, list] = models.ForeignKey( 31 | 'self', 32 | null=True, default=1, 33 | related_name='tags', 34 | on_delete=models.SET_DEFAULT 35 | ) 36 | active: bool = models.BooleanField(default=False) 37 | updated: datetime = models.DateTimeField(auto_now=True, auto_now_add=False) 38 | created_on: datetime = models.DateTimeField(auto_now=False, auto_now_add=True) 39 | 40 | objects: CategoryManager = CategoryManager() 41 | 42 | class Meta: 43 | constraints: List[Any] = [ 44 | models.UniqueConstraint( 45 | fields=['title', 'slug'], 46 | name='unique_blog_category' 47 | ), 48 | ] 49 | verbose_name_plural: str = "categories" 50 | 51 | def __repr__(self) -> str: 52 | return f"" 53 | 54 | def __str__(self) -> str: 55 | return self.title 56 | 57 | 58 | class Post(models.Model): 59 | """ 60 | Model for Blog Posts. 61 | """ 62 | user: Any = models.ForeignKey( 63 | settings.AUTH_USER_MODEL, 64 | default=1, 65 | null=True, 66 | on_delete=models.SET_DEFAULT, 67 | related_name="posts" 68 | ) 69 | category: Any = models.ForeignKey( 70 | Category, 71 | null=True, default=1, blank=True, 72 | on_delete=models.SET_DEFAULT, 73 | related_name='post_category' 74 | ) 75 | title: str = models.CharField(max_length=250) 76 | description: str = models.TextField(null=True, blank=True) 77 | content: str = models.TextField(default="Create a post.") 78 | slug: str = models.SlugField(unique=True, blank=True) 79 | thumbnail: Union[AnyUrl, str] = models.FileField( 80 | upload_to=upload_image_path, 81 | null=True, blank=True, max_length=1000 82 | ) 83 | draft: bool = models.BooleanField(default=False) 84 | publish: date = models.DateField(auto_now=False, auto_now_add=False) 85 | read_time: int = models.IntegerField(default=0) 86 | view_count:int = models.PositiveIntegerField(default=0) 87 | updated: datetime = models.DateTimeField(auto_now=True, auto_now_add=False) 88 | created_on: datetime = models.DateTimeField(auto_now=False, auto_now_add=True) 89 | 90 | objects: PostManager = PostManager() 91 | 92 | class Meta: 93 | verbose_name: str = "post" 94 | verbose_name_plural: str = "posts" 95 | ordering: list = ["-publish", "title"] 96 | 97 | def __repr__(self) -> str: 98 | return "" % self.title 99 | 100 | def __str__(self) -> str: 101 | return f"{self.title}" 102 | 103 | def get_thumbnail_url(self) -> Union[Path, str, AnyUrl]: 104 | timestamp = "%s%s%s%s%s" % (datetime.now().year, datetime.now().day, datetime.now().hour, datetime.now().minute, 105 | datetime.now().second) 106 | if self.thumbnail and hasattr(self.thumbnail, 'url'): 107 | return "%s?enc=%s" % (self.thumbnail.url, timestamp) 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | # def posts_pre_save_receiver(sender, instance, *args, **kwargs): 116 | # if not instance.slug: 117 | # instance.slug = unique_slug_generator(instance) 118 | 119 | # if instance.content: 120 | # html_string = instance.content 121 | # calc_read_length = get_read_length(html_string) 122 | # instance.read_length = calc_read_length 123 | 124 | 125 | # def category_pre_save_receiver(sender, instance, *args, **kwargs): 126 | # if not instance.slug: 127 | # instance.slug = unique_slug_generator(instance) 128 | 129 | 130 | # models.signals.pre_save.connect(posts_pre_save_receiver, sender=Post) 131 | # models.signals.pre_save.connect(category_pre_save_receiver, sender=Category) 132 | -------------------------------------------------------------------------------- /blog/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | from typing import Any, Generic, List, Optional, Type, Union 3 | 4 | from accounts.schemas import UserOut 5 | from pydantic import BaseModel, validator 6 | 7 | from blog.models import Category 8 | 9 | 10 | def confirm_title(value: str) -> str: 11 | """ 12 | Validation to prevent empty title field. 13 | Called by the helper function below; 14 | """ 15 | if not value: 16 | raise ValueError("Please provide a title.") 17 | return value 18 | 19 | def confirm_slug(value: str) -> str: 20 | """ 21 | Validation to prevent empty slug field. 22 | Called by the helperfunction below; 23 | """ 24 | if not value: 25 | raise ValueError("Slug cannot be empty.") 26 | return value 27 | 28 | 29 | class CategoryBase(BaseModel): 30 | """ 31 | Base fields for blog post category. 32 | """ 33 | title: str 34 | description: Optional[str] = None 35 | 36 | _confirm_title = validator("title", allow_reuse=True)(confirm_title) 37 | 38 | 39 | class CreateCategory(CategoryBase): 40 | """ 41 | Fields for creating blog post category. 42 | """ 43 | ... 44 | 45 | class UpdateCategory(CategoryBase): 46 | """ 47 | Fields for updating blog post category. 48 | """ 49 | # slug: str 50 | active: bool 51 | 52 | # _confirm_slug = validator("slug", allow_reuse=True)(confirm_slug) 53 | 54 | 55 | class CategoryOut(CategoryBase): 56 | """ 57 | Response for blog post category. 58 | """ 59 | slug: str 60 | active: bool 61 | 62 | class Config: 63 | orm_mode = True 64 | 65 | 66 | class CategoryListOut(BaseModel): 67 | """ 68 | Response for list all categories. 69 | We made a custom since we need just these two fields. 70 | """ 71 | title: str 72 | slug: str 73 | 74 | class Config: 75 | orm_mode = True 76 | 77 | 78 | class PostBase(BaseModel): 79 | """Base fields for blog posts.""" 80 | user: UserOut 81 | title: str 82 | 83 | # Validation for title and slug 84 | _check_title = validator("title", allow_reuse=True)(confirm_title) 85 | 86 | # @validator("title") 87 | # def check_title_availability(cls, value): 88 | # if not value: 89 | # raise ValueError("Title cannot be empty.") 90 | 91 | class CreatePost(PostBase): 92 | """ 93 | Fields for creating blog post. 94 | """ 95 | ... 96 | 97 | 98 | class UpdatePost(PostBase): 99 | """ 100 | Fields for updating blog post. 101 | """ 102 | # slug: Optional[str] = None 103 | view_count: int 104 | active: bool 105 | # updated: datetime 106 | 107 | # _check_slug = validator("slug", allow_reuse=True)(confirm_slug) 108 | 109 | 110 | class SinglePost(PostBase): 111 | """ 112 | Response for blog post. 113 | """ 114 | id: int 115 | slug: str 116 | view_count: int 117 | draft: bool = False 118 | publish: date 119 | description: Optional[str] = None 120 | content: Optional[str] = ... 121 | read_time: int 122 | category: CategoryOut 123 | 124 | class Config: 125 | orm_mode = True 126 | 127 | 128 | class AllPostList(PostBase): 129 | """ 130 | Response for listing all blog posts. 131 | Custom for just these few fields 132 | """ 133 | id: int 134 | slug: str 135 | draft: bool = False 136 | category: CategoryListOut 137 | 138 | class Config: 139 | orm_mode = True 140 | 141 | 142 | class PostByCategoryList(PostBase): 143 | """ 144 | Response for listing all blog posts. 145 | Custom for just these few fields. 146 | """ 147 | id: int 148 | slug: str 149 | draft: bool = False 150 | 151 | class Config: 152 | orm_mode = True 153 | -------------------------------------------------------------------------------- /blog/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /contact/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drmacsika/fastapi-django-combo/3025332d87a8da1ef13bfd7c6e27d23c9b4af685/contact/__init__.py -------------------------------------------------------------------------------- /contact/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from contact.models import Contact 4 | 5 | 6 | class ContactAdmin(admin.ModelAdmin): 7 | list_display = ['email', 'subject', 'active', 'created'] 8 | list_display_links = ['email'] 9 | list_filter = ['active', 'created'] 10 | search_fields = ['subject'] 11 | list_editable = ['active'] 12 | list_per_page = 10 13 | ordering = ('-id',) 14 | 15 | 16 | admin.site.register(Contact, ContactAdmin) 17 | -------------------------------------------------------------------------------- /contact/api_crud.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Union 2 | 3 | from core.base_crud import SLUGTYPE, BaseCRUD 4 | from core.utils import unique_slug_generator 5 | from fastapi import HTTPException 6 | from fastapi.encoders import jsonable_encoder 7 | 8 | from contact.models import Contact 9 | from contact.schemas import ContactCreate 10 | 11 | 12 | class ContactCRUD(BaseCRUD[Contact, ContactCreate, ContactCreate, SLUGTYPE]): 13 | 14 | def create(self, obj_in: ContactCreate) -> Contact: 15 | Contact.objects.create(**obj_in.dict()) 16 | return {"detail": "Message sent successfully!" } 17 | 18 | contact = ContactCRUD(Contact) 19 | -------------------------------------------------------------------------------- /contact/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ContactConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'contact' 7 | -------------------------------------------------------------------------------- /contact/endpoints.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | 3 | from fastapi import APIRouter 4 | 5 | from contact.api_crud import contact 6 | from contact.schemas import ContactCreate, ContactOut 7 | 8 | router = APIRouter() 9 | 10 | @router.post("/", status_code=201) 11 | def create_contact(request: ContactCreate) -> Any: 12 | """ 13 | End point for contact creation. 14 | This should always be placed above the single GET endpoint. 15 | """ 16 | return contact.create(obj_in=request) 17 | 18 | -------------------------------------------------------------------------------- /contact/managers.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class ContactManager(models.Manager): 5 | def all(self): 6 | return self.get_queryset() 7 | 8 | def active(self, *args, **kwargs): 9 | return super(ContactManager, self).filter(active=True) 10 | -------------------------------------------------------------------------------- /contact/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-10-23 01:27 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Contact', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('firstname', models.CharField(max_length=100)), 19 | ('lastname', models.CharField(max_length=100)), 20 | ('email', models.EmailField(max_length=255, unique=True)), 21 | ('subject', models.CharField(max_length=255)), 22 | ('message', models.TextField(max_length=2000)), 23 | ('active', models.BooleanField(default=False)), 24 | ('created', models.DateTimeField(auto_now_add=True)), 25 | ], 26 | options={ 27 | 'ordering': ['-created'], 28 | }, 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /contact/migrations/0002_alter_contact_email.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-10-23 01:50 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('contact', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='contact', 15 | name='email', 16 | field=models.EmailField(max_length=255), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /contact/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drmacsika/fastapi-django-combo/3025332d87a8da1ef13bfd7c6e27d23c9b4af685/contact/migrations/__init__.py -------------------------------------------------------------------------------- /contact/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.db import models 4 | 5 | from contact.managers import ContactManager 6 | 7 | 8 | class Contact(models.Model): 9 | """ 10 | Models for contacts. 11 | """ 12 | firstname: str = models.CharField(max_length=100) 13 | lastname: str = models.CharField(max_length=100) 14 | email: str = models.EmailField(max_length=255) 15 | subject: str = models.CharField(max_length=255) 16 | message: str = models.TextField(max_length=2000) 17 | active: bool = models.BooleanField(default=False) 18 | created: datetime = models.DateTimeField(auto_now=False, auto_now_add=True) 19 | 20 | objects = ContactManager() 21 | 22 | class Meta: 23 | ordering=["-created"] 24 | 25 | def __str__(self) -> str: 26 | return f"{self.email}" 27 | 28 | -------------------------------------------------------------------------------- /contact/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from re import compile, search 3 | from typing import Optional 4 | 5 | from pydantic import BaseModel, EmailStr, validator 6 | 7 | 8 | def confirm_field(value: str) -> str: 9 | """ 10 | Validation to prevent empty title field. 11 | Called by the helperfunction below; 12 | """ 13 | if not value: 14 | raise ValueError("Please fill in missing field.") 15 | return value 16 | 17 | def check_name_length(value: str) -> str: 18 | """ 19 | Check the length of the name fields 20 | """ 21 | if len(value) > 100: 22 | raise ValueError("The provided name is too large.") 23 | return value 24 | 25 | def check_special_character(value: str) -> str: 26 | """ 27 | Check the presence of special characters. 28 | """ 29 | special_characters = compile("[\" \"@_!#$%^&*()<>'\"?/\|}{~:]") 30 | if special_characters.search(value) is not None: 31 | raise ValueError("The provided name contains space or special character(s).") 32 | return value 33 | 34 | def check_short_field_length(value: str) -> str: 35 | """ 36 | Check the length of the email 37 | """ 38 | if len(value) > 255: 39 | raise ValueError("The provided email is too large.") 40 | return value 41 | 42 | 43 | def check_long_field_length(value: str) -> str: 44 | """ 45 | Check the length of the message 46 | """ 47 | if len(value) > 2000: 48 | raise ValueError("The message field is too large.") 49 | return value 50 | 51 | 52 | class ContactBase(BaseModel): 53 | """ 54 | Base fields for contact. 55 | """ 56 | firstname: str 57 | lastname: str 58 | email: EmailStr 59 | subject: str 60 | message: str 61 | # Custom validation for first name field 62 | _check_firstname = validator("firstname", allow_reuse=True)(confirm_field) 63 | _check_fn_length = validator("firstname", allow_reuse=True)(check_name_length) 64 | _check_fn_spec_chr = validator("firstname", allow_reuse=True)(check_special_character) 65 | 66 | # Custom validation for last name field 67 | _check_lastname = validator("lastname", allow_reuse=True)(confirm_field) 68 | _check_ln_length = validator("lastname", allow_reuse=True)(check_name_length) 69 | _check_ln_spec_chr = validator("lastname", allow_reuse=True)(check_special_character) 70 | 71 | # Custom validation for email field 72 | _check_email = validator("email", allow_reuse=True)(confirm_field) 73 | _check_email_length = validator("email", allow_reuse=True)(check_short_field_length) 74 | 75 | # Custom validation for message field 76 | _check_message = validator("message", allow_reuse=True)(confirm_field) 77 | _check_message_length = validator("message", allow_reuse=True)(check_long_field_length) 78 | 79 | # Custom validation for subject field. 80 | _check_subject = validator("subject", allow_reuse=True)(confirm_field) 81 | _check_subject_length = validator("subject", allow_reuse=True)(check_short_field_length) 82 | 83 | 84 | class ContactCreate(ContactBase): 85 | """ 86 | Fields for creating contacts. 87 | """ 88 | ... 89 | 90 | class ContactOut(ContactBase): 91 | """ 92 | For Contact response. 93 | """ 94 | id: Optional[int] = None 95 | active: bool = True 96 | created: Optional[datetime] = None 97 | -------------------------------------------------------------------------------- /contact/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /contact/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drmacsika/fastapi-django-combo/3025332d87a8da1ef13bfd7c6e27d23c9b4af685/core/__init__.py -------------------------------------------------------------------------------- /core/api_router.py: -------------------------------------------------------------------------------- 1 | from blog.endpoints import router as blog_router 2 | from contact.endpoints import router as contact_router 3 | from fastapi import APIRouter 4 | 5 | router = APIRouter() 6 | router.include_router(blog_router, prefix="/blog", tags=["Blog"]) 7 | router.include_router(contact_router, prefix="/contact", tags=["Contact"]) 8 | -------------------------------------------------------------------------------- /core/asgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | from importlib.util import find_spec 3 | 4 | from django.apps import apps 5 | from django.conf import settings 6 | from django.core.wsgi import get_wsgi_application 7 | from fastapi import FastAPI 8 | from fastapi.middleware.cors import CORSMiddleware 9 | from fastapi.middleware.wsgi import WSGIMiddleware 10 | from fastapi.staticfiles import StaticFiles 11 | 12 | # Export Django settings env variable 13 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") 14 | apps.populate(settings.INSTALLED_APPS) 15 | 16 | # This endpoint imports should be placed below the settings env declaration 17 | # Otherwise, django will throw a configure() settings error 18 | from core.api_router import router as api_router 19 | 20 | # Get the Django WSGI application we are working with 21 | application = get_wsgi_application() 22 | 23 | # This can be done without the function, but making it functional 24 | # tidies the entire code and encourages modularity 25 | def get_application() -> FastAPI: 26 | # Main Fast API application 27 | app = FastAPI( 28 | title=settings.PROJECT_NAME, 29 | openapi_url=f"{settings.API_V1_STR}/openapi.json", 30 | debug=settings.DEBUG 31 | ) 32 | 33 | # Set all CORS enabled origins 34 | app.add_middleware( 35 | CORSMiddleware, 36 | allow_origins=[str(origin) for origin in settings.ALLOWED_HOSTS] or ["*"], 37 | allow_credentials=True, 38 | allow_methods=["*"], 39 | allow_headers=["*"], 40 | ) 41 | 42 | # Include all api endpoints 43 | app.include_router(api_router, prefix=settings.API_V1_STR) 44 | 45 | # Mounts an independent web URL for Django WSGI application 46 | app.mount(f"{settings.WSGI_APP_URL}", WSGIMiddleware(application)) 47 | 48 | # Mounts an independent web URL for DRF API 49 | app.mount(f"{settings.DRF_V1_STR}", WSGIMiddleware(application)) 50 | 51 | # Set Up the static files and directory to serve django static files 52 | app.mount("/static", StaticFiles(directory="static"), name="static") 53 | return app 54 | 55 | 56 | app = get_application() 57 | -------------------------------------------------------------------------------- /core/base_crud.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, List, Optional, Type, TypeVar 2 | 3 | from django.db.models import Model 4 | from fastapi.encoders import jsonable_encoder 5 | from pydantic import BaseModel 6 | 7 | ModelType = TypeVar("ModelType", bound=Model) 8 | CreateSchema = TypeVar("CreateSchema", bound=BaseModel) 9 | UpdateSchema = TypeVar("UpdateSchema", bound=BaseModel) 10 | SLUGTYPE = TypeVar("SLUGTYPE", "int", "str") 11 | 12 | 13 | class BaseCRUD(Generic[ModelType, CreateSchema, UpdateSchema, SLUGTYPE]): 14 | """ 15 | Base class for all crud operations 16 | Methods to Create, Read, Update, Delete (CRUD). 17 | """ 18 | def __init__(self, model: Type[ModelType]): 19 | self.model = model 20 | 21 | def get(self, slug: SLUGTYPE) -> Optional[ModelType]: 22 | """ 23 | Get single item. 24 | """ 25 | return self.model.objects.get(slug=slug) 26 | 27 | def get_multiple(self, limit:int = 100, offset:int = 0) -> List[ModelType]: 28 | """ 29 | get multiple items using a query limiting flag. 30 | """ 31 | return self.model.objects.all()[offset:offset+limit] 32 | 33 | def create(self, obj_in: CreateSchema) -> ModelType: 34 | """ 35 | Create an item. 36 | """ 37 | if not isinstance(obj_in, list): 38 | obj_in = jsonable_encoder(obj_in) 39 | return self.model.objects.create(**obj_in) 40 | 41 | def update(self, obj_in: UpdateSchema, slug: SLUGTYPE) -> ModelType: 42 | """ 43 | Update an item. 44 | """ 45 | if not isinstance(obj_in, list): 46 | obj_in = jsonable_encoder(obj_in) 47 | return self.model.objects.filter(slug=slug).update(**obj_in) 48 | 49 | def delete(self, slug: SLUGTYPE) -> ModelType: 50 | """Delete an item.""" 51 | self.model.objects.filter(slug=slug).delete() 52 | return {"detail": "Successfully deleted!"} 53 | -------------------------------------------------------------------------------- /core/settings.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from pathlib import Path 3 | 4 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 5 | BASE_DIR = Path(__file__).resolve().parent.parent 6 | 7 | 8 | 9 | # SECURITY WARNING: keep the secret key used in production secret! 10 | SECRET_KEY = 'django-insecure-zndek@2rmxim9bjb)1+9eoaj0i@9ho02h8dt2fx6!pq8jptyqg' 11 | 12 | # SECURITY WARNING: don't run with debug turned on in production! 13 | DEBUG = True 14 | 15 | ALLOWED_HOSTS = [] 16 | 17 | API_V1_STR: str = "/api/fa/v1" 18 | DRF_V1_STR: str = "/api/drf/v1" 19 | WSGI_APP_URL: str = "/web" 20 | AUTH_USER_MODEL = "accounts.CustomUser" 21 | 22 | # Application definition 23 | 24 | INSTALLED_APPS = [ 25 | 'django.contrib.admin', 26 | 'django.contrib.auth', 27 | 'django.contrib.contenttypes', 28 | 'django.contrib.sessions', 29 | 'django.contrib.messages', 30 | 'django.contrib.staticfiles', 31 | 32 | # Package Dependencies 33 | 'django.contrib.sites', 34 | 35 | # Third party 36 | 'rest_framework', 37 | "rest_framework.authtoken", 38 | 'corsheaders', 39 | 'allauth', 40 | 'allauth.account', 41 | 'allauth.socialaccount', 42 | # 'allauth.socialaccount.providers.facebook', 43 | # 'allauth.socialaccount.providers.google', 44 | 'dj_rest_auth', 45 | "dj_rest_auth.registration", 46 | 47 | # Custom Apps 48 | "blog", 49 | "accounts", 50 | "contact", 51 | ] 52 | 53 | MIDDLEWARE = [ 54 | 'django.middleware.security.SecurityMiddleware', 55 | 'django.contrib.sessions.middleware.SessionMiddleware', 56 | 'corsheaders.middleware.CorsMiddleware', 57 | 'django.middleware.common.CommonMiddleware', 58 | 'django.middleware.csrf.CsrfViewMiddleware', 59 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 60 | 'django.contrib.messages.middleware.MessageMiddleware', 61 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 62 | ] 63 | 64 | ROOT_URLCONF = 'core.urls' 65 | 66 | TEMPLATES = [ 67 | { 68 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 69 | 'DIRS': [BASE_DIR / 'templates'], 70 | 'APP_DIRS': True, 71 | 'OPTIONS': { 72 | 'context_processors': [ 73 | 'django.template.context_processors.debug', 74 | 'django.template.context_processors.request', 75 | 'django.contrib.auth.context_processors.auth', 76 | 'django.contrib.messages.context_processors.messages', 77 | ], 78 | }, 79 | }, 80 | ] 81 | 82 | WSGI_APPLICATION = 'core.wsgi.application' 83 | 84 | 85 | # Database 86 | 87 | DATABASES = { 88 | 'default': { 89 | 'ENGINE': 'django.db.backends.sqlite3', 90 | 'NAME': BASE_DIR / 'db.sqlite3', 91 | } 92 | } 93 | 94 | 95 | # Password validation 96 | 97 | AUTH_PASSWORD_VALIDATORS = [ 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 100 | }, 101 | { 102 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 103 | }, 104 | { 105 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 106 | }, 107 | { 108 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 109 | }, 110 | ] 111 | 112 | 113 | # Internationalization 114 | 115 | LANGUAGE_CODE = 'en-us' 116 | 117 | TIME_ZONE = 'UTC' 118 | 119 | USE_I18N = True 120 | 121 | USE_L10N = True 122 | 123 | USE_TZ = True 124 | 125 | 126 | # Static files (CSS, JavaScript, Images) 127 | # Static files 128 | STATIC_URL = "/static/" 129 | STATICFILES_DIRS = [BASE_DIR / "static", ] 130 | STATIC_ROOT = BASE_DIR / "static" 131 | 132 | # Media files 133 | MEDIA_URL = "/media/" 134 | MEDIA_ROOT = BASE_DIR / "media" 135 | 136 | # Default primary key field type 137 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 138 | 139 | 140 | PROJECT_NAME = "Archangel Macsika" 141 | 142 | ###### Custom settings ###### 143 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 144 | # EMAIL_HOST = 'your.email.host' 145 | # EMAIL_USE_TLS = True 146 | # EMAIL_PORT = 587 147 | # EMAIL_HOST_USER = 'your email host user' 148 | # EMAIL_HOST_PASSWORD = 'your email host password' 149 | SITE_ID = 1 150 | AUTH_USER_MODEL = 'accounts.CustomUser' 151 | # LOGIN_URL = 'http://127.0.0.1:3000' # reverse_lazy('accounts:login') 152 | # LOGIN_REDIRECT_URL = 'home' 153 | # LOGOUT_URL = 'accounts:logout' 154 | # LOGOUT_REDIRECT_URL = 'home' 155 | # SIGNUP_REDIRECT_URL = 'accounts:email_verification_sent' 156 | 157 | 158 | REST_FRAMEWORK = { 159 | "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication", 'dj_rest_auth.jwt_auth.JWTCookieAuthentication',), 160 | # "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), 161 | } 162 | 163 | SIMPLE_JWT = { 164 | "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30), 165 | "REFRESH_TOKEN_LIFETIME": timedelta(days=1), 166 | "ROTATE_REFRESH_TOKENS": False, 167 | "AUTH_HEADER_TYPES": ("Bearer",), 168 | "AUTH_TOKEN_CLASSSES": ("rest_framework_simplejwt.tokens.AccessToken",), 169 | } 170 | 171 | CORS_ALLOWED_ORIGINS = [ 172 | "http://localhost:3000", 173 | "http://127.0.0.1:3000", 174 | ] 175 | 176 | # dj_rest_auth 177 | REST_USE_JWT = True 178 | JWT_AUTH_COOKIE = 'access' 179 | JWT_AUTH_REFRESH_COOKIE = 'refresh' 180 | REST_AUTH_SERIALIZERS = { 181 | 'REGISTER_SERIALIZER': 'accounts.serializers.CustomRegisterSerializer', 182 | 'USER_DETAILS_SERIALIZER': 'accounts.serializers.CustomUserDetailsSerializer', 183 | 'PASSWORD_RESET_SERIALIZER': 'accounts.serializers.CustomPasswordResetSerializer', 184 | } 185 | OLD_PASSWORD_FIELD_ENABLED = True 186 | LOGOUT_ON_PASSWORD_CHANGE = False 187 | 188 | # All Auth 189 | AUTHENTICATION_BACKENDS = ( 190 | # Needed to login by username in Django admin, regardless of `allauth` 191 | "django.contrib.auth.backends.ModelBackend", 192 | 193 | # `allauth` specific authentication methods, such as login by e-mail 194 | "allauth.account.auth_backends.AuthenticationBackend", 195 | ) 196 | 197 | ADAPTER = 'accounts.adapter.MyAccountAdapter' 198 | ACCOUNT_ADAPTER = 'accounts.adapter.MyAccountAdapter' 199 | ACCOUNT_AUTHENTICATION_METHOD = 'email' 200 | ACCOUNT_CONFIRM_EMAIL_ON_GET = False 201 | ACCOUNT_DEFAULT_HTTP_PROTOCOL = "http" 202 | ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL = None 203 | ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 1 204 | ACCOUNT_EMAIL_CONFIRMATION_HMAC = True 205 | ACCOUNT_EMAIL_MAX_LENGTH = 255 206 | ACCOUNT_EMAIL_REQUIRED = True 207 | ACCOUNT_EMAIL_VERIFICATION = 'mandatory' # 'optional' 208 | # ACCOUNT_FORMS = {'reset_password': 'accounts.forms.CustomResetPasswordForm'} 209 | ACCOUNT_LOGOUT_ON_GET = False 210 | # ACCOUNT_LOGOUT_REDIRECT_URL = 'home' 211 | ACCOUNT_PRESERVE_USERNAME_CASING = False 212 | ACCOUNT_USERNAME_REQUIRED = False 213 | # ACCOUNT_SESSION_REMEMBER = True 214 | ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False 215 | ACCOUNT_UNIQUE_EMAIL = True 216 | ACCOUNT_USER_MODEL_USERNAME_FIELD = None 217 | ACCOUNT_USERNAME_BLACKLIST = ["admin"] 218 | -------------------------------------------------------------------------------- /core/urls.py: -------------------------------------------------------------------------------- 1 | from accounts.views import CustomPasswordResetConfirmView 2 | from django.contrib import admin 3 | from django.urls import path 4 | from django.urls.conf import include, path 5 | from rest_framework_simplejwt.views import (TokenObtainPairView, 6 | TokenRefreshView, TokenVerifyView) 7 | 8 | from core.settings import DRF_V1_STR 9 | 10 | urlpatterns = [ 11 | 12 | # Simple JWT 13 | path("token/", TokenObtainPairView.as_view()), 14 | path("token/refresh/", TokenRefreshView.as_view()), 15 | path("token/verify/", TokenVerifyView.as_view()), 16 | 17 | # rest_framework 18 | path("", include('rest_framework.urls')), 19 | 20 | # The pasword reset path below can only work 21 | # if added to the high level urls.py file 22 | path( 23 | "accounts/password/reset///", 24 | CustomPasswordResetConfirmView.as_view(), name='password_reset_confirm' 25 | ), 26 | path("", include("accounts.urls")), 27 | path('admin/', admin.site.urls), 28 | ] 29 | -------------------------------------------------------------------------------- /core/utils.py: -------------------------------------------------------------------------------- 1 | import math 2 | # from spamfighter.models import BlockedIps 3 | import os 4 | import random 5 | import re 6 | import string 7 | import unicodedata 8 | 9 | from django.core.exceptions import ValidationError 10 | from django.utils.html import strip_tags 11 | from django.utils.text import slugify 12 | from django.utils.translation import gettext_lazy as _ 13 | 14 | # 2.5MB - 2621440 15 | # 5MB - 5242880 16 | # 10MB - 10485760 17 | # 20MB - 20971520 18 | # 50MB - 5242880 19 | # 100MB 104857600 20 | # 250MB - 214958080 21 | # 500MB - 429916160 22 | MAX_UPLOAD_SIZE = "5242880" 23 | 24 | 25 | def random_string_generator(size=10, chars=string.ascii_lowercase + string.digits): 26 | return ''.join(random.choice(chars) for _ in range(size)) 27 | 28 | 29 | def unique_key_generator(instance): 30 | size = random.randint(30, 45) 31 | key_id = random_string_generator(size=size) 32 | 33 | Klass = instance.__class__ 34 | qs_exists = Klass.objects.filter(key=key_id).exists() 35 | if qs_exists: 36 | return unique_slug_generator(instance) 37 | return key_id 38 | 39 | 40 | def unique_slug_generator(instance, new_slug=None): 41 | if new_slug is not None: 42 | slug = new_slug 43 | else: 44 | slug = slugify(instance.title) 45 | 46 | Klass = instance.__class__ 47 | qs_exists = Klass.objects.filter(slug=slug).exists() 48 | if qs_exists: 49 | new_slug = "{slug}-{randstr}".format( 50 | slug=slug, 51 | randstr=random_string_generator(size=4) 52 | ) 53 | return unique_slug_generator(instance, new_slug=new_slug) 54 | return slug 55 | 56 | 57 | def get_ip_address(request): 58 | ip = request.META.get('HTTP_X_FORWARDED_FOR') # more reliable 59 | if ip: 60 | ip = ip.split(',')[0] 61 | else: 62 | ip = request.META.get('REMOTE_ADDR') # less reliable 63 | return ip 64 | 65 | 66 | # def check_ip_address(ip): 67 | # return BlockedIps.objects.filter(ip_address=ip).exists() 68 | 69 | 70 | def validate_doc_file_extension(value): 71 | ext = os.path.splitext(value.name)[1] 72 | valid_extensions = ['.pdf', '.doc', '.docx'] 73 | if not ext in valid_extensions: 74 | raise ValidationError( 75 | _('File not supported!'), 76 | code='invalid', 77 | params={'value': value}, 78 | ) 79 | else: 80 | return value 81 | 82 | 83 | def validate_doc_image_file_extension(value): 84 | ext = os.path.splitext(value.name)[1] # [0] returns path+filename 85 | valid_extensions = ['.pdf', '.doc', '.docx', '.jpg', 86 | '.png', '.xlsx', '.xls', '.txt', '.zip', '.rar'] 87 | if not ext.lower() in valid_extensions: 88 | raise ValidationError( 89 | _('Unsupported file extension.'), 90 | code='invalid', 91 | params={'value': value}, 92 | ) 93 | else: 94 | return value 95 | 96 | 97 | # Size less than # 5MB - 5242880 98 | def validate_file_size(value): 99 | filesize = value.size 100 | if filesize < 0 or filesize > 5242880: 101 | raise ValidationError( 102 | _("The file size is unacceptable! Enter size less than 5MB."), 103 | code='invalid', 104 | params={'value': value}, 105 | ) 106 | else: 107 | return value 108 | 109 | 110 | def validate_fullname(self): 111 | fullname = self.split() 112 | if len(fullname) <= 1: 113 | raise ValidationError( 114 | _('Kindly enter more than one name, please.'), 115 | code='invalid', 116 | params={'value': self}, 117 | ) 118 | for x in fullname: 119 | if x.isalpha() is False or len(x) < 2: 120 | raise ValidationError( 121 | _('Please enter your name correctly.'), 122 | code='invalid', 123 | params={'value': self}, 124 | ) 125 | 126 | 127 | def get_first_name(self): 128 | if isinstance(self, bool): 129 | pass 130 | else: 131 | names = self.split() 132 | return names[0] 133 | 134 | 135 | def get_last_name(self): 136 | if isinstance(self, bool): 137 | pass 138 | else: 139 | names = self.split() 140 | return names[-1] 141 | 142 | 143 | def generate_username(self, full_name, Model): 144 | name = full_name.lower() 145 | name = name.split(' ') 146 | lastname = name[-1] 147 | firstname = name[0] 148 | self.username = '%s%s' % (firstname[0], lastname) 149 | if Model.objects.filter(username=self.username).count() > 0: 150 | username = '%s%s' % (firstname, lastname[0]) 151 | if Model.objects.filter(username=self.username).count() > 0: 152 | users = Model.objects.filter(username__regex=r'^%s[1-9]{1,}$' % firstname).order_by( 153 | 'username').values( 154 | 'username') 155 | if len(users) > 0: 156 | last_number_used = sorted( 157 | map(lambda x: int(x['username'].replace(firstname, '')), users)) 158 | last_number_used = last_number_used[-1] 159 | number = last_number_used + 1 160 | self.username = '%s%s' % (firstname, number) 161 | else: 162 | self.username = '%s%s' % (firstname, 1) 163 | return self.username 164 | 165 | 166 | 167 | def random_string(size: int, chars: str = string.ascii_lowercase+string.digits) -> str: 168 | """ 169 | Generate random strings from a given size 170 | """ 171 | return "".join(random.choices(chars, k = size)) 172 | 173 | 174 | def slugify(value, allow_unicode=False): 175 | """ 176 | Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated 177 | dashes to single dashes. Remove characters that aren't alphanumerics, 178 | underscores, or hyphens. Convert to lowercase. Also strip leading and 179 | trailing whitespace, dashes, and underscores. 180 | """ 181 | value = str(value) 182 | if allow_unicode: 183 | value = unicodedata.normalize('NFKC', value) 184 | else: 185 | value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii') 186 | value = re.sub(r'[^\w\s-]', '', value.lower()) 187 | return re.sub(r'[-\s]+', '-', value).strip('-_') 188 | 189 | 190 | def unique_slug_generator(value, new_slug=False): 191 | """ 192 | This generates a unique slug using your model slug value 193 | assuming there's a model with a slug field and 194 | a title character (char) field. 195 | If a slug exists, it generates a unique slug with the old and random 196 | otherwise, it generates a new slug 197 | """ 198 | if new_slug: 199 | return f"{slugify(value)}-{random_string(4)}" 200 | return slugify(value) 201 | 202 | 203 | def count_words(content): 204 | """Count all the words received from a parameter.""" 205 | matching_words = re.findall(r'\w+', content) 206 | count = len(matching_words) 207 | return count 208 | 209 | 210 | def get_read_time(content): 211 | """Get the read length by dividing with an average of 200wpm """ 212 | count = count_words(content) 213 | read_length_min = math.ceil(count/200.0) 214 | return int(read_length_min) 215 | -------------------------------------------------------------------------------- /core/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for core project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | anyio==3.3.4 2 | asgiref==3.4.1 3 | certifi==2021.10.8 4 | cffi==1.15.0 5 | charset-normalizer==2.0.7 6 | click==8.0.3 7 | cryptography==35.0.0 8 | defusedxml==0.7.1 9 | dj-rest-auth==2.1.11 10 | Django==4.0.0 11 | django-allauth==0.45.0 12 | django-cors-headers==3.10.0 13 | djangorestframework==3.12.4 14 | djangorestframework-simplejwt==5.0.0 15 | dnspython==2.1.0 16 | email-validator==1.1.3 17 | fastapi==0.70.0 18 | h11==0.12.0 19 | idna==3.3 20 | oauthlib==3.1.1 21 | Pillow==8.4.0 22 | pycparser==2.20 23 | pydantic==1.8.2 24 | PyJWT==2.3.0 25 | python3-openid==3.2.0 26 | pytz==2021.3 27 | requests==2.26.0 28 | requests-oauthlib==1.3.0 29 | sniffio==1.2.0 30 | sqlparse==0.4.2 31 | starlette==0.16.0 32 | typing-extensions==3.10.0.2 33 | urllib3==1.26.7 34 | uvicorn==0.15.0 35 | -------------------------------------------------------------------------------- /templates/Screenshot 2021-10-23 at 23.12.52.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drmacsika/fastapi-django-combo/3025332d87a8da1ef13bfd7c6e27d23c9b4af685/templates/Screenshot 2021-10-23 at 23.12.52.png -------------------------------------------------------------------------------- /templates/Screenshot 2021-10-23 at 23.13.05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drmacsika/fastapi-django-combo/3025332d87a8da1ef13bfd7c6e27d23c9b4af685/templates/Screenshot 2021-10-23 at 23.13.05.png -------------------------------------------------------------------------------- /templates/Screenshot 2021-10-23 at 23.13.42.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drmacsika/fastapi-django-combo/3025332d87a8da1ef13bfd7c6e27d23c9b4af685/templates/Screenshot 2021-10-23 at 23.13.42.png -------------------------------------------------------------------------------- /templates/Screenshot 2021-10-23 at 23.13.51.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drmacsika/fastapi-django-combo/3025332d87a8da1ef13bfd7c6e27d23c9b4af685/templates/Screenshot 2021-10-23 at 23.13.51.png -------------------------------------------------------------------------------- /templates/accounts/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block head_title %}{% endblock %} 5 | {% block extra_head %} 6 | {% endblock %} 7 | 8 | 9 | {% block body %} 10 | 11 | {% if messages %} 12 |
13 | Messages: 14 |
    15 | {% for message in messages %} 16 |
  • {{message}}
  • 17 | {% endfor %} 18 |
19 |
20 | {% endif %} 21 | 22 |
23 | Menu: 24 | 33 |
34 | {% block content %} 35 | {% endblock %} 36 | {% endblock %} 37 | {% block extra_body %} 38 | {% endblock %} 39 | 40 | -------------------------------------------------------------------------------- /templates/accounts/email/base_message.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% autoescape off %}{% blocktrans with site_name=current_site.name %}Hello from {{ site_name }}!{% endblocktrans %} 2 | 3 | {% block content %}{% endblock %} 4 | 5 | {% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Thank you for using {{ site_name }}! 6 | {{ site_domain }}{% endblocktrans %} 7 | {% endautoescape %} 8 | -------------------------------------------------------------------------------- /templates/accounts/email/email_confirmation_message.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drmacsika/fastapi-django-combo/3025332d87a8da1ef13bfd7c6e27d23c9b4af685/templates/accounts/email/email_confirmation_message.html -------------------------------------------------------------------------------- /templates/accounts/email/email_confirmation_message.txt: -------------------------------------------------------------------------------- 1 | {% extends "accounts/email/base_message.txt" %} 2 | {% load account %} 3 | {% load i18n %} 4 | 5 | {% block content %}{% autoescape off %}{% user_display user as user_display %}{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Accounts - You're receiving this e-mail because user {{ user_display }} has given your e-mail address to register an account on {{ site_domain }}. 6 | 7 | To confirm this is correct, go to {{ activate_url }}{% endblocktrans %}{% endautoescape %}{% endblock %} -------------------------------------------------------------------------------- /templates/accounts/email/email_confirmation_signup_message.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drmacsika/fastapi-django-combo/3025332d87a8da1ef13bfd7c6e27d23c9b4af685/templates/accounts/email/email_confirmation_signup_message.html -------------------------------------------------------------------------------- /templates/accounts/email/email_confirmation_signup_message.txt: -------------------------------------------------------------------------------- 1 | {% include "accounts/email/email_confirmation_message.txt" %} 2 | -------------------------------------------------------------------------------- /templates/accounts/email/email_confirmation_signup_subject.txt: -------------------------------------------------------------------------------- 1 | {% include "accounts/email/email_confirmation_subject.txt" %} 2 | -------------------------------------------------------------------------------- /templates/accounts/email/email_confirmation_subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% autoescape off %} 3 | {% blocktrans %}Please Confirm Your E-mail Address{% endblocktrans %} 4 | {% endautoescape %} -------------------------------------------------------------------------------- /templates/accounts/email/password_reset_key_message.txt: -------------------------------------------------------------------------------- 1 | {% extends "accounts/email/base_message.txt" %} 2 | {% load i18n %} 3 | 4 | {% block content %}{% autoescape off %}{% blocktrans %}Yippe Yada Yada! You're receiving this e-mail because you or someone else has requested a password for your user account. 5 | It can be safely ignored if you did not request a password reset. Click the link below to reset your password.{% endblocktrans %} 6 | 7 | {{ password_reset_url }}{% if username %} 8 | 9 | {% blocktrans %}In case you forgot, your username is {{ username }}.{% endblocktrans %}{% endif %}{% endautoescape %}{% endblock %} -------------------------------------------------------------------------------- /templates/accounts/email/password_reset_key_subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% autoescape off %} 3 | {% blocktrans %}Password Reset E-mail{% endblocktrans %} 4 | {% endautoescape %} 5 | -------------------------------------------------------------------------------- /templates/accounts/email/verified_email_required.html: -------------------------------------------------------------------------------- 1 | {% extends "accounts/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Verify Your E-mail Address" %}{% endblock %} 6 | 7 | {% block content %} 8 |

{% trans "Verify Your E-mail Address" %}

9 | 10 | {% url 'account_email' as email_url %} 11 | 12 |

{% blocktrans %}This part of the site requires us to verify that 13 | you are who you claim to be. For this purpose, we require that you 14 | verify ownership of your e-mail address. {% endblocktrans %}

15 | 16 |

{% blocktrans %}We have sent an e-mail to you for 17 | verification. Please click on the link inside this e-mail. Please 18 | contact us if you do not receive it within a few minutes.{% endblocktrans %}

19 | 20 |

{% blocktrans %}Note: you can still change your e-mail address.{% endblocktrans %}

21 | 22 | 23 | {% endblock %} -------------------------------------------------------------------------------- /templates/accounts/email_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends "accounts/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load account %} 5 | 6 | {% block head_title %}{% trans "Confirm E-mail Address" %}{% endblock %} 7 | 8 | 9 | {% block content %} 10 |

{% trans "Confirm E-mail Address" %}

11 | 12 | {% if confirmation %} 13 | 14 | {% user_display confirmation.email_address.user as user_display %} 15 | 16 |

{% blocktrans with confirmation.email_address.email as email %}Please confirm that {{ email }} is an e-mail address for user {{ user_display }}.{% endblocktrans %}

17 | 18 |
19 | {% csrf_token %} 20 | 21 |
22 | 23 | {% else %} 24 | 25 | {% url 'account_email' as email_url %} 26 | 27 |

{% blocktrans %}This e-mail confirmation link expired or is invalid. Please issue a new e-mail confirmation request.{% endblocktrans %}

28 | 29 | {% endif %} 30 | 31 | {% endblock %} -------------------------------------------------------------------------------- /templates/accounts/login.html: -------------------------------------------------------------------------------- 1 | {% load custom_tags %} 2 |

Log In from Accounts {{ request.path }}

3 | 4 | {% url "accounts:login" as login_url %} 5 |
6 | {% csrf_token %} 7 | {{ form.as_p }} 8 | 9 |
10 | 11 |
12 | Log In 13 |
14 | 15 | 16 | 17 | {% comment %} 18 | {% load custom_tags %} 19 |

Log In from Accounts {% url_path request.META.HTTP_REFERER %}

20 |
21 | {% csrf_token %} 22 | {{ form.as_p }} 23 | 24 | 25 |
26 | 27 |
28 | Log In 29 |
30 | {% endcomment %} -------------------------------------------------------------------------------- /templates/accounts/logout.html: -------------------------------------------------------------------------------- 1 | {% extends "accounts/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Sign Out" %}{% endblock %} 6 | 7 | {% block content %} 8 |

{% trans "Sign Out" %}

9 | 10 |

{% trans 'Are you sure you want to sign out?' %}

11 | 12 |
13 | {% csrf_token %} 14 | {% if redirect_field_value %} 15 | 16 | {% endif %} 17 | 18 |
19 | 20 | 21 | {% endblock %} -------------------------------------------------------------------------------- /templates/accounts/password_change.html: -------------------------------------------------------------------------------- 1 | {% extends "accounts/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Change Password" %}{% endblock %} 6 | 7 | {% block content %} 8 |

{% trans "Change Password" %}

9 | 10 |
11 | {% csrf_token %} 12 | {{ form.as_p }} 13 | 14 | {% trans "Forgot Password?" %} 15 |
16 | {% endblock %} -------------------------------------------------------------------------------- /templates/accounts/password_reset.html: -------------------------------------------------------------------------------- 1 | {% extends "accounts/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load account %} 5 | 6 | {% block head_title %}{% trans "Password Reset" %}{% endblock %} 7 | 8 | {% block content %} 9 | 10 |

{% trans "Password Reset" %}

11 | {% if user.is_authenticated %} 12 | {% include "accounts/snippets/already_logged_in.html" %} 13 | {% endif %} 14 | 15 |

{% trans "Forgotten your password? Enter your e-mail address below, and we'll send you an e-mail allowing you to reset it." %}

16 | 17 |
18 | {% csrf_token %} 19 | {{ form.as_p }} 20 | 21 |
22 | 23 |

{% blocktrans %}Please contact us if you have any trouble resetting your password.{% endblocktrans %}

24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /templates/accounts/password_reset_done.html: -------------------------------------------------------------------------------- 1 | {% extends "accounts/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load account %} 5 | 6 | {% block head_title %}{% trans "Password Reset" %}{% endblock %} 7 | 8 | {% block content %} 9 |

{% trans "Password Reset" %}

10 | 11 | {% if user.is_authenticated %} 12 | {% include "accounts/snippets/already_logged_in.html" %} 13 | {% endif %} 14 | 15 |

{% blocktrans %}We have sent you an e-mail. Please contact us if you do not receive it within a few minutes.{% endblocktrans %}

16 | {% endblock %} -------------------------------------------------------------------------------- /templates/accounts/password_reset_from_key.html: -------------------------------------------------------------------------------- 1 | {% extends "accounts/base.html" %} 2 | 3 | {% load i18n %} 4 | {% block head_title %}{% trans "Custom Change Password" %}{% endblock %} 5 | 6 | {% block content %} 7 |

{% if token_fail %}{% trans "Custom Bad Token" %}{% else %}{% trans "Change Password" %}{% endif %}

8 | 9 | {% if token_fail %} 10 | {% url 'accounts:reset_password' as passwd_reset_url %} 11 |

{% blocktrans %}The password reset link was invalid, possibly because it has already been used. Please request a new password reset.{% endblocktrans %}

12 | {% else %} 13 | {% if form %} 14 |
15 | {% csrf_token %} 16 | {{ form.as_p }} 17 | 18 |
19 | {% else %} 20 |

{% trans 'Your password is now changed.' %}

21 | {% endif %} 22 | {% endif %} 23 | {% endblock %} -------------------------------------------------------------------------------- /templates/accounts/password_reset_from_key_done.html: -------------------------------------------------------------------------------- 1 | {% extends "accounts/base.html" %} 2 | 3 | {% load i18n %} 4 | {% block head_title %}{% trans "Change Password" %}{% endblock %} 5 | 6 | {% block content %} 7 |

{% trans "Change Password" %}

8 |

{% trans 'Your password is now changed.' %}

9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /templates/accounts/password_set.html: -------------------------------------------------------------------------------- 1 | {% extends "accounts/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Set Password" %}{% endblock %} 6 | 7 | {% block content %} 8 |

{% trans "Set Password" %}

9 | 10 |
11 | {% csrf_token %} 12 | {{ form.as_p }} 13 | 14 |
15 | {% endblock %} -------------------------------------------------------------------------------- /templates/accounts/registration/password_reset_email.html: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% autoescape off %} 2 | {% blocktranslate %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktranslate %} 3 | 4 | {% translate "Please go to the following page and choose a new password:" %} 5 | {% block reset_link %} 6 | {{ protocol }}://{{ domain }}{% url 'accounts:password_reset_confirm' uidb64=uid token=token %} 7 | {% endblock %} 8 | {% translate 'Your username, in case you’ve forgotten:' %} {{ user.get_username }} 9 | 10 | {% translate "Thanks for using our site!" %} 11 | 12 | {% blocktranslate %Archangel {{ site_name }} team{% endblocktranslate %} 13 | 14 | {% endautoescape %} -------------------------------------------------------------------------------- /templates/accounts/signup.html: -------------------------------------------------------------------------------- 1 |

Sign Up

2 |
3 | {% csrf_token %} 4 | {{ form.as_p }} 5 | 6 |
-------------------------------------------------------------------------------- /templates/accounts/snippets/already_logged_in.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load account %} 3 | 4 | {% user_display user as user_display %} 5 |

{% trans "Note" %}: {% blocktrans %}you are already logged in as {{ user_display }}.{% endblocktrans %}

6 | -------------------------------------------------------------------------------- /templates/accounts/verification_sent.html: -------------------------------------------------------------------------------- 1 | {% extends "accounts/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Verify Your E-mail Address" %}{% endblock %} 6 | 7 | {% block content %} 8 |

{% trans "Verify Your E-mail Address" %}

9 | 10 |

{% blocktrans %}Are you There? We have sent an e-mail to you for verification. Follow the link provided to finalize the signup process. Please contact us if you do not receive it within a few minutes.{% endblocktrans %}

11 | 12 | {% endblock %} -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block head_title %}{% endblock %} 5 | {% block extra_head %} 6 | {% endblock %} 7 | 8 | 9 | {% block body %} 10 | 11 | {% if messages %} 12 |
13 | Messages: 14 |
    15 | {% for message in messages %} 16 |
  • {{message}}
  • 17 | {% endfor %} 18 |
19 |
20 | {% endif %} 21 | 22 |
23 | Menu: 24 | 33 |
34 | {% block content %} 35 | {% endblock %} 36 | {% endblock %} 37 | {% block extra_body %} 38 | {% endblock %} 39 | 40 | -------------------------------------------------------------------------------- /templates/pages/about.html: -------------------------------------------------------------------------------- 1 |
2 |

About Page

3 | 7 |
8 | 9 | {% if user.is_authenticated %} 10 | Hi {{ user.email }}! 11 |

Log Out

12 | {% else %} 13 |

You are not logged in

14 | Log In | 15 | Sign Up 16 | {% endif %} -------------------------------------------------------------------------------- /templates/pages/home.html: -------------------------------------------------------------------------------- 1 |
2 |

Home Page

3 | 7 |
8 | 9 | {% if user.is_authenticated %} 10 | Hi {{ user.email }}! 11 |

Log Out

12 | {% else %} 13 |

You are not logged in

14 | Log In | 15 | Sign Up 16 | {% endif %} --------------------------------------------------------------------------------