diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml new file mode 100644 index 0000000..fefb08a --- /dev/null +++ b/.github/workflows/pre-commit.yaml @@ -0,0 +1,50 @@ +name: Pre-commit + +on: [push, pull_request] + +jobs: + pre-commit: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11"] + + steps: + - name: Checkout source + uses: actions/checkout@v3 + + ## Setup Python, dependencies and migrations, pre-commit needs them + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run migrations + run: | + python manage.py makemigrations + python manage.py migrate + + - name: Check linter + run: | + black --check . + + - name: Check coverage + run: | + if [ ! -e coverage/.coverage ] || [ ! -e coverage/coverage.svg ]; then + echo "Have you run pre-commit?" + exit 1 + else + echo "coverage found" + fi + coverage run --data-file=coverage/.coverage-CI manage.py test + coverage report > coverage.txt + coverage report --data-file=coverage/.coverage-CI > coverage-CI.txt + diff coverage.txt coverage-CI.txt + coverage-badge -f -o coverage/coverage-CI.svg + diff coverage/coverage.svg coverage/coverage-CI.svg + echo "Pre-commit correct" \ No newline at end of file diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..1a63dbc --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,31 @@ +name: Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12"] + + steps: + - name: Checkout source + uses: actions/checkout@v3 + + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run migrations ## Migraciones para tener la BBDD como necesita la app. + run: | + python manage.py makemigrations + python manage.py migrate + - name: Run tests + run: python manage.py test \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0edea30 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +__pycache__ +.env +*/migrations/* +!*/migrations/__init__.py +media +db.sqlite3 \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3efb534 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +repos: + - repo: local + hooks: + - id: linter + name: linter + entry: | + black . + language: system + types: [python] + + - id: run-code-coverage + name: Run Code Coverage + entry: | + coverage run manage.py test + language: system + pass_filenames: false + types: [python] + + - id: create-coverage-badge + name: Create coverage badge + entry: | + coverage-badge -f -o coverage/coverage.svg + language: system + pass_filenames: false + types: [python] \ No newline at end of file diff --git a/License.txt b/License.txt new file mode 100644 index 0000000..135d98a --- /dev/null +++ b/License.txt @@ -0,0 +1,7 @@ +Copyright © 2024 + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd73cf9 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +![CI](https://github.com/tsenovilla/django_extended_accounts/actions/workflows/tests.yaml/badge.svg) +![pre-commit](https://github.com/tsenovilla/django_extended_accounts/actions/workflows/pre-commit.yaml/badge.svg) +![Coverage](./coverage/coverage.svg) +![Black](https://img.shields.io/badge/code%20style-black-000000.svg) + +# Description 📖📚 + +**django_extended_accounts** is a Django app designed to extend the Django's default authentication system. As this is a common task, this project aims to be a reusable template to help developers saving a lot of time. + +This solution is based on getting rid of the Django's User model to use a custom model called AccountModel. This model is designed to only contain authentication-related information, while all personal data resides in another model called ProfileModel, which maintains a one-to-one relationship with AccountModel. The AccountModel provided is similar to the default User model in Django, with some differences: + +- It does not include first and last names; these are stored in ProfileModel. +- Upon saving, it executes an additional query to save profile information in ProfileModel. +- Accounts are initially deactivated, requiring users to activate them via email after creation. + +The decision behind this design is to keep the authentication model as simple as possible and avoid interference with other apps using this solution, allowing each app to specify its own user data requirements without conflicting assumptions. However, as this is just a template, you can change this design choice if you feel it's worthy of your project. + +The ProfileModel contains initially the following fields: + +- First name. +- Last name. +- Phone number. +- Profile Image + +Since this is a template, other fields aren't included for simplicity, but as the app is fully customizable this model may be altered to fit your project's requirements. + +The provided app deals with some usual concepts present in many website accounts' system, such as: + +- Allows users to upload a profile image, which is automatically converted into WebP format for efficiency while also saving the original format. If the user updates/deletes the image or the user itself is deleted, the former is automatically removed from the server. + +- Sends a confirmation email to the user once it creates its account. If the account is not confirmed in an arbitrary period of time, the account is removed from the ddbb. This is achieved by integrating Celery into the project as a daemon. + +Feel free to add/remove any functionality needed by your project. + +##### Important Considerations ⚠️ ❗️ + +- Reusable apps generally shouldn't implement a custom user model as specified in [Django docs](https://docs.djangoproject.com/en/5.0/topics/auth/customizing/#reusable-apps-and-auth-user-model). However, this app is an exception as it specifically deals with extending the user model. +- Changing the authentication model mid-project is non-trivial so this app should only be used in new projects. Check [Django docs](https://docs.djangoproject.com/en/5.0/topics/auth/customizing/#changing-to-a-custom-user-model-mid-project) for further information. +- Usually, you won't interact with the ProfileModel directly but through the AccountModel. AccountModel comes with two safe methods to which you can happily pass any argument related to ProfileModel. +These methods will automatically handle that data for you. In addition, these methods ensure the integrity of data if something goes wrong, as they rollback the database if saving to AccountModel or ProfileModel fails. +Using other methods such as ``save`` or ``create`` may cause undesired behavior, so use them only if you're perfectly fine with them. +The safe methods are: + + - `create_user`: Use this method through the model manager to create a new account. Ex: ``Account.objects.create_user(username='johndoe', password='johndoe', email='johndoe@mail.com', phone_number=123456789)`` + + - `update`: Use this method directly on the model instance to update an account. Ex: ``account.update(username='johndoe', phone_number=123456789, first_name='John')`` + + +## DRF Version 📱💡 + +If your project uses Django Rest Framework to construct an API, the [DRF version](https://github.com/tsenovilla/django_extended_accounts_api) may be more suitable for your needs. + + +## Usage 📌🖇️ + +This application serves as a template for extending the Django user model. It is meant to be customized, extended, or reduced according to project requirements. Since it is not published on PyPI, the code is open for editing as needed in your project. Therefore, to use it you can follow this general guidelines: + +1. Copy the application into your Django project. +2. Add specific configurations detailed at the end of `django_extended_accounts/settings.py` to your project's settings. +3. Add URLs as specified in `django_extended_accounts/urls.py`. +4. If using Celery, add `django_extended_accounts/celery.py` to the project's main folder (where `settings.py` resides). + +For the sake of simplicity, this project uses development configurations in some tasks such as image uploading or email sending. For production projects, configurations should be adapted. + +## Note on Celery Integration 🤝 + +Refer to the Celery documentation ([Celery Documentation](https://docs.celeryq.dev/en/stable/userguide/configuration.html)) for comprehensive configuration details. The included configuration is minimal for simplicity. + +If you feel that your project doesn't need Celery, you can happily remove it from the template following the next steps: + +- Remove Celery from the project's requirements. +- Delete Celery configurations in `django_extended_accounts/settings.py`. +- Remove `django_extended_accounts/celery.py`, `extended_accounts/helpers/tasks.py`, and `extended_accounts/signals/post_save_account_model.py`. + +## Contributing 📝 + +Any contribution is more than welcome! 😸🤝🦾 + +We use pre-commit to ensure code formatting with Black and to run code coverage, CI checks that this's been correctly done :). Please, adhere to these standards if you want to contribute. + diff --git a/coverage/.coverage b/coverage/.coverage new file mode 100644 index 0000000..c7fbd61 Binary files /dev/null and b/coverage/.coverage differ diff --git a/coverage/coverage.svg b/coverage/coverage.svg new file mode 100644 index 0000000..e5db27c --- /dev/null +++ b/coverage/coverage.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/django_extended_accounts/__init__.py b/django_extended_accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_extended_accounts/asgi.py b/django_extended_accounts/asgi.py new file mode 100644 index 0000000..94b6a7e --- /dev/null +++ b/django_extended_accounts/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for django_extended_accounts project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_extended_accounts.settings") + +application = get_asgi_application() diff --git a/django_extended_accounts/celery.py b/django_extended_accounts/celery.py new file mode 100644 index 0000000..14b3121 --- /dev/null +++ b/django_extended_accounts/celery.py @@ -0,0 +1,18 @@ +import os +from celery import Celery + +# Set the default configuration file +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_extended_accounts.settings") + +celery_app = Celery( + "django_extended_accounts" +) ## Rename the app with your project's name :) + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means that all celery-related configuration keys +# should have a prefix `CELERY_`. +celery_app.config_from_object("django.conf:settings", namespace="CELERY") + +# Import celery task modules registered in all Django apps +celery_app.autodiscover_tasks() diff --git a/django_extended_accounts/settings.py b/django_extended_accounts/settings.py new file mode 100644 index 0000000..89370fd --- /dev/null +++ b/django_extended_accounts/settings.py @@ -0,0 +1,110 @@ +from django.urls import reverse_lazy +from pathlib import Path +import os +import sys + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = "django-insecure-5_z8k5snwu(im+iuha=@fo=p&bvp$#+_zilfdaozn2ugnm3g04" + +DEBUG = True + +ALLOWED_HOSTS = [] + + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "django_extended_accounts.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "django_extended_accounts.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + + +STATIC_URL = "static/" +STATIC_ROOT = os.path.join(BASE_DIR, STATIC_URL) +MEDIA_URL = "media/" +MEDIA_ROOT = os.path.join(BASE_DIR, MEDIA_URL) + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +## Settings related to the django_extended_account apps start here! +## Make sure to add them into your settings.py +## The settings are related to a dev environment, make sure to adapt accordingly with your needs + +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +DEFAULT_FROM_EMAIL = "mail@mail.com" +TESTING = "test" in sys.argv +INTEGRATION_TEST_CELERY = False + +## Celery settings. +## Here the configuration is minimal, refer to the official docs https://docs.celeryq.dev/en/stable/userguide/configuration.html to check out all the availables options. If you're not using Celery in your project, you can happily delete them. +CELERY_BROKER_URL = "pyamqp://" + + +## extended_accounts app + +AUTH_USER_MODEL = "extended_accounts.AccountModel" +INSTALLED_APPS += ["extended_accounts"] +LOGIN_URL = reverse_lazy("extended_accounts:login") +LOGIN_REDIRECT_URL = reverse_lazy("extended_accounts:redirect_account") +LOGOUT_REDIRECT_URL = reverse_lazy("extended_accounts:login") diff --git a/django_extended_accounts/urls.py b/django_extended_accounts/urls.py new file mode 100644 index 0000000..cc456b6 --- /dev/null +++ b/django_extended_accounts/urls.py @@ -0,0 +1,17 @@ +from django.urls import path, include +from django.conf import settings + +urlpatterns = [] + +## If you are using the extended_accounts app +urlpatterns += [ + path( + "extended_accounts/", + include("extended_accounts.urls", namespace="extended_accounts"), + ) +] +## Use this setting to correctly visualize your images in development django templates. +if settings.DEBUG: + from django.conf.urls.static import static + + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/django_extended_accounts/wsgi.py b/django_extended_accounts/wsgi.py new file mode 100644 index 0000000..ec89cec --- /dev/null +++ b/django_extended_accounts/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for django_extended_accounts project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_extended_accounts.settings") + +application = get_wsgi_application() diff --git a/extended_accounts/__init__.py b/extended_accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/extended_accounts/apps.py b/extended_accounts/apps.py new file mode 100644 index 0000000..c8d5eb3 --- /dev/null +++ b/extended_accounts/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class ExtendedAccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "extended_accounts" + + def ready(self): + # Import the signals + from .signals import __all__ + + return super().ready() diff --git a/extended_accounts/helpers/__init__.py b/extended_accounts/helpers/__init__.py new file mode 100644 index 0000000..b3a75ae --- /dev/null +++ b/extended_accounts/helpers/__init__.py @@ -0,0 +1,3 @@ +from .new_account_form import NewAccountForm +from .update_account_form import UpdateAccountForm +from .tasks import delete_unconfirmed_accounts diff --git a/extended_accounts/helpers/new_account_form.py b/extended_accounts/helpers/new_account_form.py new file mode 100644 index 0000000..6db11b1 --- /dev/null +++ b/extended_accounts/helpers/new_account_form.py @@ -0,0 +1,70 @@ +from django import forms +from django.contrib.auth.forms import UserCreationForm +from django.core.validators import RegexValidator +from django.core.exceptions import ValidationError +from extended_accounts.models import AccountModel as Account + + +class NewAccountForm(UserCreationForm): + first_name = forms.CharField(max_length=150, required=True, label="First Name") + last_name = forms.CharField(max_length=150, required=True, label="Last Name") + email = forms.EmailField(max_length=254, required=True) + phone_number = forms.IntegerField( + required=True, + validators=[ + RegexValidator( + regex=r"^[0-9]{9}$", + message="Phone number must contain 9 digits", ## This validation is due to spanish phone numbers are composed by 9 digits, adapt it accordingly with your needs + ) + ], + label="Phone Number", + ) + profile_image = forms.ImageField(required=False, label="Profile image") + + def __init__(self, *args, **kwargs): ## We remove the help texts which look ugly + super().__init__(*args, **kwargs) + self.fields["password1"].help_text = None + self.fields["password2"].help_text = None + + def save( + self, + ): ## This form is used to create a user, so it's to call the create_user function in the Account model's manager when saving + self.cleaned_data.pop("password1") + self.cleaned_data["password"] = self.cleaned_data.pop( + "password2" + ) ## If cleaned data, password1==password2 contains the password information + return Account.objects.create_user(**self.cleaned_data) + + def clean_email(self): + email = self.cleaned_data.get("email") + try: # Check if the email is already associated with an user + Account.objects.get(email=email) + except Account.DoesNotExist: + pass + else: + raise ValidationError( + "The email provided is already registered by another user." + ) + return email + + def clean_phone_number(self): + phone_number = self.cleaned_data.get("phone_number") + try: # Check if the phone number is already associated with an user + Account.objects.get(profile__phone_number=phone_number) + except Account.DoesNotExist: + pass + else: + raise ValidationError( + "The phone number provided is already registered by another user." + ) + return phone_number + + class Meta(UserCreationForm.Meta): + model = Account + fields = UserCreationForm.Meta.fields + ( + "first_name", + "last_name", + "email", + "phone_number", + "profile_image", + ) diff --git a/extended_accounts/helpers/tasks.py b/extended_accounts/helpers/tasks.py new file mode 100644 index 0000000..d29a27d --- /dev/null +++ b/extended_accounts/helpers/tasks.py @@ -0,0 +1,13 @@ +from extended_accounts.models import AccountModel as Account +from celery import shared_task + + +# This task will be called by the post_save signal of Account when one hour has passed since registration. If the user has not been confirmed, it will be deleted from the database. +@shared_task +def delete_unconfirmed_accounts(username): + try: + account = Account.objects.get(username=username) + if not account.is_active: + account.delete() + except Account.DoesNotExist: # If it doesn't exist, there's nothing to do. + pass diff --git a/extended_accounts/helpers/tests/__init__.py b/extended_accounts/helpers/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/extended_accounts/helpers/tests/test_new_account_form.py b/extended_accounts/helpers/tests/test_new_account_form.py new file mode 100644 index 0000000..94f3aae --- /dev/null +++ b/extended_accounts/helpers/tests/test_new_account_form.py @@ -0,0 +1,130 @@ +from django.test import TestCase, override_settings +from django.core.files.uploadedfile import SimpleUploadedFile +from extended_accounts.helpers import NewAccountForm +from extended_accounts.models import AccountModel as Account +from PIL import Image +from io import BytesIO +import tempfile, shutil, os + +MEDIA_ROOT = tempfile.mkdtemp() + + +def create_test_image(): + image_buffer = BytesIO() + image_object = Image.new("RGB", (1, 1)) + image_object.save(image_buffer, "png") + image_buffer.seek(0) + image = SimpleUploadedFile( + "test_image.png", + image_buffer.read(), + ) + return image + + +@override_settings(MEDIA_ROOT=MEDIA_ROOT) +class NewAccountFormTestCase(TestCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + Account.objects.create_user( + username="jdoe", + first_name="John", + last_name="Doe", + email="jdoe@mail.com", + phone_number=987654321, + password="passwordtest", + ) ## Register an user to test the form errors + cls.data = { + "username": "johndoe", + "first_name": "John", + "last_name": "Doe", + "email": "johndoe@mail.com", + "phone_number": 123456789, + "password1": "passwordtest", + "password2": "passwordtest", + } + + @classmethod + def tearDownClass(cls, *args, **kwargs): + shutil.rmtree(MEDIA_ROOT, ignore_errors=True) + super().tearDownClass(*args, **kwargs) + + def __modify_data(self, to_modify): + data = self.data.copy() + data.update(to_modify) + return data + + def test_init_form(self): + form = NewAccountForm(self.data) + self.assertIsNone(form.fields["password1"].help_text) + self.assertIsNone(form.fields["password2"].help_text) + + def test_save_form_creates_account_OK(self): + form = NewAccountForm(self.data, files={"profile_image": create_test_image()}) + self.assertTrue(form.is_valid()) + account = form.save() + self.assertEqual(account.username, self.data["username"]) + self.assertEqual(account.email, self.data["email"]) + self.assertTrue(account.check_password(self.data["password1"])) + self.assertEqual(account.profile.first_name, self.data["first_name"]) + self.assertEqual(account.profile.last_name, self.data["last_name"]) + self.assertEqual(account.profile.phone_number, self.data["phone_number"]) + self.assertIn( + account.profile.profile_image.name + ".png", os.listdir(MEDIA_ROOT) + ) + self.assertIn( + account.profile.profile_image.name + ".webp", os.listdir(MEDIA_ROOT) + ) + + ## VALIDATORS TESTS + def test_validator_OK(self): + form = NewAccountForm(self.data) + self.assertTrue(form.is_valid()) + + def test_validator_no_first_name_KO(self): + data = self.__modify_data({"first_name": ""}) + form = NewAccountForm(data) + self.assertFalse(form.is_valid()) + + def test_validator_no_last_name_KO(self): + data = self.__modify_data({"last_name": ""}) + form = NewAccountForm(data) + self.assertFalse(form.is_valid()) + + def test_validator_no_email_KO(self): + data = self.__modify_data({"email": ""}) + form = NewAccountForm(data) + self.assertFalse(form.is_valid()) + + def test_validator_duplicate_email_KO(self): + data = self.__modify_data({"email": "jdoe@mail.com"}) + form = NewAccountForm(data) + self.assertFalse(form.is_valid()) + + def test_validator_no_phone_number_KO(self): + data = self.__modify_data({"phone_number": ""}) + form = NewAccountForm(data) + self.assertFalse(form.is_valid()) + + def test_validator_malformed_phone_number_KO(self): + ## Malformed phone number (incorrect length) + data = self.__modify_data({"phone_number": 123}) + form = NewAccountForm(data) + self.assertFalse(form.is_valid()) + + ## Malformed phone number (not even numbers) + data = self.__modify_data({"phone_number": "123frc789"}) + form = NewAccountForm(data) + self.assertFalse(form.is_valid()) + + def test_validator_duplicate_phone_number_KO(self): + data = self.__modify_data({"phone_number": 987654321}) + form = NewAccountForm(data) + self.assertFalse(form.is_valid()) + + def test_validator_all_incorrect_KO(self): + data = self.__modify_data( + {"first_name": "", "last_name": "", "email": "", "phone_number": 987654321} + ) + form = NewAccountForm(data) + self.assertFalse(form.is_valid()) diff --git a/extended_accounts/helpers/tests/test_tasks.py b/extended_accounts/helpers/tests/test_tasks.py new file mode 100644 index 0000000..2fce75b --- /dev/null +++ b/extended_accounts/helpers/tests/test_tasks.py @@ -0,0 +1,30 @@ +from django.test import TestCase +from extended_accounts.models import AccountModel as Account +from extended_accounts.helpers import delete_unconfirmed_accounts + + +class TasksTestCase(TestCase): + ## Unit test for the Celery task delete_unconfirmed_accounts. It is tested synchronously and completed with the integration test in tests/integracion/test_integracion_celery which verifies that the task is called when creating a user + def test_delete_unconfirmed_user(self): + Account.objects.create_user( + username="user_1", phone_number=123456789, email="user1@mail.com" + ) ## This user will not be confirmed and therefore will be deleted + Account.objects.create_user( + username="user_2", + phone_number=987654321, + email="user2@mail.com", + is_active=True, + ) + ## Before the Celery task is executed, the users exist + self.assertTrue(Account.objects.filter(username="user_1").exists()) + self.assertTrue(Account.objects.filter(username="user_2").exists()) + ## Unit test: we can call the Celery task synchronously. + delete_unconfirmed_accounts.s(username="user_1").apply() + delete_unconfirmed_accounts.s(username="user_2").apply() + ## Check again if the users exist + self.assertFalse(Account.objects.filter(username="user_1").exists()) + self.assertTrue(Account.objects.filter(username="user_2").exists()) + + ## If the task is launched on a non-existent user (for example because the user deletes it before the task completes), no exception is raised + def test_non_existent_user_non_blocking(self): + delete_unconfirmed_accounts.s(username="user_1").apply() diff --git a/extended_accounts/helpers/tests/test_update_account_form.py b/extended_accounts/helpers/tests/test_update_account_form.py new file mode 100644 index 0000000..facc7c8 --- /dev/null +++ b/extended_accounts/helpers/tests/test_update_account_form.py @@ -0,0 +1,162 @@ +from django.test import TestCase, override_settings +from django.core.files.uploadedfile import SimpleUploadedFile +from extended_accounts.models import AccountModel as Account +from extended_accounts.helpers import UpdateAccountForm +from PIL import Image +from io import BytesIO +import tempfile, shutil, os + +MEDIA_ROOT = tempfile.mkdtemp() + + +def create_test_image(): + image_buffer = BytesIO() + image_object = Image.new("RGB", (1, 1)) + image_object.save(image_buffer, "png") + image_buffer.seek(0) + image = SimpleUploadedFile( + "test_image.png", + image_buffer.read(), + ) + return image + + +@override_settings(MEDIA_ROOT=MEDIA_ROOT) +class UpdateAccountFormTestCase(TestCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.initial_data = { + "username": "johndoe", + "first_name": "John", + "last_name": "Doe", + "email": "johndoe@mail.com", + "phone_number": 123456789, + } + cls.account = Account.objects.create_user(**cls.initial_data) + + @classmethod + def tearDownClass(cls, *args, **kwargs): + shutil.rmtree(MEDIA_ROOT, ignore_errors=True) + super().tearDownClass(*args, **kwargs) + + def __modify_data(self, to_modify): + data = self.initial_data.copy() + data.update(to_modify) + return data + + def test_init_form(self): + form = UpdateAccountForm(data=self.initial_data, instance=self.account) + self.assertEqual(form.account, self.account) + self.assertNotIn("last_login", form.fields.keys()) + self.assertNotIn("is_superuser", form.fields.keys()) + self.assertNotIn("groups", form.fields.keys()) + self.assertNotIn("user_permissions", form.fields.keys()) + self.assertNotIn("is_staff", form.fields.keys()) + self.assertNotIn("is_active", form.fields.keys()) + self.assertNotIn("date_joined", form.fields.keys()) + self.assertNotIn("password", form.fields.keys()) + + def test_save_form_updates_account_OK(self): + data = self.__modify_data( + {"username": "jdoe", "phone_number": 987654321, "first_name": "Johnny"} + ) + form = UpdateAccountForm( + data=data, + instance=self.account, + files={"profile_image": create_test_image()}, + ) + self.assertTrue(form.is_valid()) + account = form.save() + self.assertEqual(account.username, data["username"]) + self.assertEqual(account.email, self.initial_data["email"]) + self.assertEqual(account.profile.first_name, data["first_name"]) + self.assertEqual(account.profile.last_name, self.initial_data["last_name"]) + self.assertEqual(account.profile.phone_number, data["phone_number"]) + self.assertIn( + account.profile.profile_image.name + ".png", os.listdir(MEDIA_ROOT) + ) + self.assertIn( + account.profile.profile_image.name + ".webp", os.listdir(MEDIA_ROOT) + ) + + ## VALIDATORS TESTS + + def test_validator_OK(self): + self.account.refresh_from_db() # Note that the update function called by the form updates the in-memory object at the same time it updates the ddbb, so as we are using always the same self.account object, we can have an in-memory object with values corresponding to the test that effectively updated the object, so we hace to refresh it. + form = UpdateAccountForm(data=self.initial_data, instance=self.account) + self.assertTrue(form.is_valid()) + + def test_validator_OK_if_email_not_duplicate(self): + self.account.refresh_from_db() + data = self.__modify_data({"email": "other@mail.com"}) + form = UpdateAccountForm(data=data, instance=self.account) + self.assertTrue(form.is_valid()) + + def test_validator_OK_if_phone_number_not_duplicate(self): + self.account.refresh_from_db() + data = self.__modify_data({"phone_number": 987654321}) + form = UpdateAccountForm(data=data, instance=self.account) + self.assertTrue(form.is_valid()) + + def test_validator_no_first_name_KO(self): + self.account.refresh_from_db() + data = self.__modify_data({"first_name": ""}) + form = UpdateAccountForm(data=data, instance=self.account) + self.assertFalse(form.is_valid()) + + def test_validator_no_last_name_KO(self): + self.account.refresh_from_db() + data = self.__modify_data({"last_name": ""}) + form = UpdateAccountForm(data=data, instance=self.account) + self.assertFalse(form.is_valid()) + + def test_validator_no_email_KO(self): + self.account.refresh_from_db() + data = self.__modify_data({"email": ""}) + form = UpdateAccountForm(data=data, instance=self.account) + self.assertFalse(form.is_valid()) + + def test_validator_duplicate_email_KO(self): + self.account.refresh_from_db() + Account.objects.create_user( + username="other", email="other@mail.com", phone_number=987654321 + ) + data = self.__modify_data({"email": "other@mail.com"}) + form = UpdateAccountForm(data=data, instance=self.account) + self.assertFalse(form.is_valid()) + + def test_validator_no_phone_number_KO(self): + self.account.refresh_from_db() + data = self.__modify_data({"phone_number": ""}) + form = UpdateAccountForm(data=data, instance=self.account) + self.assertFalse(form.is_valid()) + + def test_validator_duplicate_phone_number_KO(self): + self.account.refresh_from_db() + Account.objects.create_user( + username="other", email="other@mail.com", phone_number=987654321 + ) + data = self.__modify_data({"phone_number": 987654321}) + form = UpdateAccountForm(data=data, instance=self.account) + self.assertFalse(form.is_valid()) + + def test_validator_malformed_phone_number_KO(self): + self.account.refresh_from_db() + ## Malformed phone number (incorrect length) + data = self.__modify_data({"phone_number": "123"}) + form = UpdateAccountForm(data=data, instance=self.account) + self.assertFalse(form.is_valid()) + + ## Malformed phone number (not even numbers) + data = self.__modify_data({"phone_number": "123frc789"}) + form = UpdateAccountForm(data=data, instance=self.account) + self.assertFalse(form.is_valid()) + + def test_validator_all_incorrect_KO(self): + self.account.refresh_from_db() + data = self.__modify_data( + {"first_name": "", "last_name": "", "email": "", "phone_number": 123} + ) + form = UpdateAccountForm(data=data, instance=self.account) + self.assertFalse(form.is_valid()) diff --git a/extended_accounts/helpers/update_account_form.py b/extended_accounts/helpers/update_account_form.py new file mode 100644 index 0000000..5f75a6b --- /dev/null +++ b/extended_accounts/helpers/update_account_form.py @@ -0,0 +1,69 @@ +from django import forms +from django.contrib.auth.forms import UserChangeForm +from django.core.validators import RegexValidator +from django.core.exceptions import ValidationError +from extended_accounts.models import AccountModel as Account + + +class UpdateAccountForm(UserChangeForm): + first_name = forms.CharField(max_length=150, required=True, label="First Name") + last_name = forms.CharField(max_length=150, required=True, label="Last Name") + email = forms.EmailField(max_length=254, required=True) + phone_number = forms.IntegerField( + required=True, + validators=[ + RegexValidator( + regex=r"^[0-9]{9}$", + message="Phone number must contain 9 digits", ## This validation is due to spanish phone numbers are composed by 9 digits, adapt it accordingly with your needs + ) + ], + label="Phone Number", + ) + profile_image = forms.ImageField(required=False, label="Profile Image") + + def __init__( + self, *args, **kwargs + ): ## We delete all the fields from the ChangeForm that shouldn't be updated by the users themselves (password update requires a special view) + super().__init__(*args, **kwargs) + del self.fields["last_login"] + del self.fields["is_superuser"] + del self.fields["groups"] + del self.fields["user_permissions"] + del self.fields["is_staff"] + del self.fields["is_active"] + del self.fields["password"] + ## We define the user and the profile throughout the form since we need it to clean of the email and the phone number. There is no problem with exceptions with the get method called below since if this view is accessed it is because the user is registered and therefore both user and profile exist. + self.account = Account.objects.get(username=self.initial["username"]) + + def clean_email(self): + email = self.cleaned_data.get("email") + try: # Check if the email is already associated with another user + account_using_email = Account.objects.get(email=email) + if account_using_email != self.account: + raise ValidationError( + "The email provided is already registered by another user." + ) + except Account.DoesNotExist: + pass + return email + + def clean_phone_number(self): + phone_number = self.cleaned_data.get("phone_number") + try: # Check if the phone number is already associated with a user + account_using_phone = Account.objects.get( + profile__phone_number=phone_number + ) + if account_using_phone != self.account: + raise ValidationError( + "The phone number provided is already registered by another user." + ) + except Account.DoesNotExist: + pass + return phone_number + + def save(self): ## Need to add this in order to + self.account.update(**self.cleaned_data) + return self.account + + class Meta(UserChangeForm.Meta): + model = Account diff --git a/extended_accounts/integration/__init__.py b/extended_accounts/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/extended_accounts/integration/tests_integration_celery_post_save_account_signal.py b/extended_accounts/integration/tests_integration_celery_post_save_account_signal.py new file mode 100644 index 0000000..078b1f9 --- /dev/null +++ b/extended_accounts/integration/tests_integration_celery_post_save_account_signal.py @@ -0,0 +1,17 @@ +from django.test import TestCase +from django.conf import settings +from extended_accounts.models import AccountModel as Account +from unittest.mock import patch + + +## Integration test to ensure that the Celery task is correctly called asynchronously. +## A mock of the real task is made to verify that it has been called with the established input and the correct time counter. +class IntegrationCeleryTest(TestCase): + @patch("extended_accounts.helpers.tasks.delete_unconfirmed_accounts.apply_async") + def test_integration_signal_post_save_user(self, mock_celery_call): + settings.INTEGRATION_TEST_CELERY = ( + True ## Activate this flag so that the post-save user signal is run + ) + Account.objects.create_user(username="user_1", phone_number=123456789) + mock_celery_call.assert_called_with(args=["user_1"], countdown=900) + settings.INTEGRATION_TEST_CELERY = False diff --git a/extended_accounts/migrations/__init__.py b/extended_accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/extended_accounts/models/Account.py b/extended_accounts/models/Account.py new file mode 100644 index 0000000..1d3578b --- /dev/null +++ b/extended_accounts/models/Account.py @@ -0,0 +1,180 @@ +from django.db import models, transaction +from django.apps import apps +from django.contrib import auth +from django.contrib.auth.models import ( + BaseUserManager, + AbstractBaseUser, + PermissionsMixin, +) +from django.contrib.auth.validators import UnicodeUsernameValidator +from django.contrib.auth.hashers import make_password +from django.utils.translation import gettext_lazy as _ + + +class AccountManager(BaseUserManager): + use_in_migrations = True + + def _create_user(self, username, password, **extra_fields): + """ + Create and save a user with the given username and password and the profile information in an associated profile model + """ + from .Profile import ProfileModel as Profile + + if not username: + raise ValueError("The given username must be set") + # Lookup the real model class from the global app registry so this + # manager method can be used in migrations. This is fine because + # managers are by definition working on the real model. + GlobalUserModel = apps.get_model( + self.model._meta.app_label, self.model._meta.object_name + ) + username = GlobalUserModel.normalize_username(username) + is_staff = extra_fields.pop("is_staff", False) + is_superuser = extra_fields.pop("is_superuser", False) + is_active = extra_fields.pop( + "is_active", False + ) # Default for is_active will be False, so users must activate their accounts with an email + email = self.normalize_email(extra_fields.pop("email", None)) + account = self.model( + username=username, + email=email, + is_staff=is_staff, + is_superuser=is_superuser, + is_active=is_active, + ) + account.password = make_password(password) + with transaction.atomic(): ## Atomic transaction, if anything goes wrong everything must be rolled back + account.save(using=self._db) + Profile.objects.create(account=account, **extra_fields) + return account + + def create_user(self, username, password=None, **extra_fields): + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_superuser", False) + return self._create_user(username, password, **extra_fields) + + def create_superuser(self, username, password=None, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + if extra_fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True.") + + return self._create_user(username, password, **extra_fields) + + def with_perm( + self, perm, is_active=True, include_superusers=True, backend=None, obj=None + ): + if backend is None: + backends = auth._get_backends(return_tuples=True) + if len(backends) == 1: + backend, _ = backends[0] + else: + raise ValueError( + "You have multiple authentication backends configured and " + "therefore must provide the `backend` argument." + ) + elif not isinstance(backend, str): + raise TypeError( + "backend must be a dotted import path string (got %r)." % backend + ) + else: + backend = auth.load_backend(backend) + if hasattr(backend, "with_perm"): + return backend.with_perm( + perm, + is_active=is_active, + include_superusers=include_superusers, + obj=obj, + ) + return self.none() + + +class AccountModel(AbstractBaseUser, PermissionsMixin): + """ + An account class that almost defaults to the standard Django User for simplicity. Here the authentication model for your project may be customized. This design's been chosen because we need to extend the default user behavior to properly link the auth properties (defined by this model) and the profile properties(those that are not related to authentication, defined by ProfileModel). Taking the default Django User model code allows to fully customize the accounts from here, while linking them with their profile data. + We say it almost defaults to the standard Django User because we remove some fields that are not directly related with autentication in django.contrib.auth.models.User (first_name, last_name, ...) and send them to the profile model, so it's slightly different. This allows us keeping this model just for authentication, it also allows each app to specify its own user data requirements without potentially conflicting or breaking assumptions by other app. As a counterpart, more queries are required to work with the model, so maybe you prefer to store everything in this model, sacrifying the flexibility mentioned above. + """ + + username_validator = UnicodeUsernameValidator() + + username = models.CharField( + _("username"), + max_length=150, + unique=True, + validators=[username_validator], + error_messages={ + "unique": _("A user with that username already exists."), + }, + ) + email = models.EmailField(_("email address"), unique=True) + is_staff = models.BooleanField( + _("staff status"), + default=False, + help_text=_("Designates whether the user can log into this admin site."), + ) + is_active = models.BooleanField( + _("active"), + default=False, + help_text=_( + "Designates whether this user should be treated as active. " + "Unselect this instead of deleting accounts." + ), + ) + + objects = AccountManager() + + EMAIL_FIELD = "email" + USERNAME_FIELD = "username" + REQUIRED_FIELDS = ["email"] + + class Meta: + verbose_name = _("user") + verbose_name_plural = _("users") + swappable = "AUTH_USER_MODEL" + + def update(self, **kwargs): + from .Profile import ProfileModel as Profile + + ## Get the fields associated with the profile. We discard _state, id, account_id and date joined as they shouldn't be manually updated + profile_fields = list(Profile().__dict__.keys()) + profile_fields.remove("_state") + profile_fields.remove("id") + profile_fields.remove("account_id") + profile_fields.remove("date_joined") + ## Get the fields related to the profile inside the update requested fields + profile_update_requested_fields = { + k: kwargs.pop(k) for k in profile_fields if k in kwargs + } + ## Update + self.profile.__dict__.update(**profile_update_requested_fields) + try: + username = kwargs.pop("username") + except ( + KeyError + ): ## If username isn't part of the kwargs, it's not being updated, so this is OK + pass + else: + if not username: ## Check that the username passed is not an empty string + raise ValueError("username cannot be empty") + kwargs["username"] = self.normalize_username(username) + try: + email = kwargs.pop("email") + except ( + KeyError + ): ## If email isn't part of the kwargs, it's not being updated, so this is OK + pass + else: + if not email: ## Check that the username passed is not an empty string + raise ValueError("emailcannot be empty") + kwargs["email"] = AccountManager().normalize_email(email) + self.__dict__.update(**kwargs) + try: + with transaction.atomic(): ## Atomic transaction, if something goes wrong, everything must be rolled back + self.save() + self.profile.save() + except Exception as e: + self.refresh_from_db() ## If something went wrong, re-synchronize self with the ddbb (the __dict__.update operations changed our in_memory object) + raise e diff --git a/extended_accounts/models/Profile.py b/extended_accounts/models/Profile.py new file mode 100644 index 0000000..a1dcc60 --- /dev/null +++ b/extended_accounts/models/Profile.py @@ -0,0 +1,27 @@ +from django.db import models +from django.utils import timezone +from django.conf import settings +from uuid import uuid4 + + +def unique_image_name(instance, filename): + """ + This function is used by ImageField's upload_to in order to get a unique name for an updated image, obtained via uuid4. + WARNING: You might be tempted to use a lambda function instead of this one. That works perfectly when the application is running, but it fails when we make Django migrations. This is due to lambda functions cannot be serialized, which is a requirement for Django's migration framework. Therefore, to achieve a more consistent app, it is better to use this one. + """ + return uuid4().hex + "." + filename.split(".")[-1] + + +class ProfileModel(models.Model): + first_name = models.CharField(max_length=150) + last_name = models.CharField(max_length=150) + phone_number = models.IntegerField( + unique=True, null=True + ) ## We have to allow null here, otherwise it'll throw an error when creating users from the CLI. Otherwise we must pass this field to the Account Model but it's not worthy of that. It's OK with theoretically allowing null but we won't allow it in forms or serializers. + profile_image = models.ImageField( + upload_to=unique_image_name, default=None, null=True + ) + date_joined = models.DateTimeField(default=timezone.now) + account = models.OneToOneField( + settings.AUTH_USER_MODEL, related_name="profile", on_delete=models.CASCADE + ) diff --git a/extended_accounts/models/__init__.py b/extended_accounts/models/__init__.py new file mode 100644 index 0000000..f109194 --- /dev/null +++ b/extended_accounts/models/__init__.py @@ -0,0 +1,2 @@ +from .Account import AccountModel +from .Profile import ProfileModel diff --git a/extended_accounts/models/tests/__init__.py b/extended_accounts/models/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/extended_accounts/models/tests/test_account.py b/extended_accounts/models/tests/test_account.py new file mode 100644 index 0000000..b724acd --- /dev/null +++ b/extended_accounts/models/tests/test_account.py @@ -0,0 +1,225 @@ +from django.test import TestCase, override_settings +from django.core.files.uploadedfile import SimpleUploadedFile +from django.contrib.auth.models import Permission +from django.contrib.auth.backends import BaseBackend +from django.contrib.contenttypes.models import ContentType +from django.db.utils import IntegrityError +from extended_accounts.models import AccountModel as Account +from PIL import Image +from io import BytesIO +import tempfile, shutil, os + +MEDIA_ROOT = tempfile.mkdtemp() + + +def create_test_image(): + image_buffer = BytesIO() + image_object = Image.new("RGB", (1, 1)) + image_object.save(image_buffer, "png") + image_buffer.seek(0) + image = SimpleUploadedFile( + "test_image.png", + image_buffer.read(), + ) + return image + + +class UselessBackend( + BaseBackend +): ## Use this backend to test that the manager returns manager.none() when calling with_perm if the used backend doesn't have a with_perm attribute + pass + + +@override_settings(MEDIA_ROOT=MEDIA_ROOT) +class AccountModelTestCase(TestCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.initial_data = { + "username": "johndoe", + "first_name": "John", + "last_name": "Doe", + "email": "johndoe@mail.com", + "phone_number": 123456789, + "profile_image": create_test_image(), + "password": "test_password", + } + cls.account = Account.objects.create_user(**cls.initial_data) + content_type = ContentType.objects.get_for_model(Account) + cls.permission = Permission.objects.create( + codename="can_do_something", + name="Can Do Something", + content_type=content_type, + ) + cls.account.user_permissions.add( + cls.permission + ) ## Add the permission to the user + + @classmethod + def tearDownClass(cls, *args, **kwargs): + shutil.rmtree(MEDIA_ROOT, ignore_errors=True) + super().tearDownClass(*args, **kwargs) + + def __modify_data(self, to_modify): + data = self.initial_data.copy() + data.update(to_modify) + return data + + ## CREATE_USER TESTS + + def test_create_user_OK(self): + self.assertEqual(self.account.username, self.initial_data["username"]) + self.assertEqual(self.account.email, self.initial_data["email"]) + self.assertTrue(self.account.check_password(self.initial_data["password"])) + self.assertFalse(self.account.is_superuser) + self.assertFalse(self.account.is_staff) + self.assertFalse(self.account.is_active) + self.assertEqual( + self.account.profile.first_name, self.initial_data["first_name"] + ) + self.assertEqual(self.account.profile.last_name, self.initial_data["last_name"]) + self.assertEqual( + self.account.profile.phone_number, self.initial_data["phone_number"] + ) + self.assertIn( + self.account.profile.profile_image.name + ".png", os.listdir(MEDIA_ROOT) + ) + self.assertIn( + self.account.profile.profile_image.name + ".webp", os.listdir(MEDIA_ROOT) + ) + + def test_create_user_rollback_OK_if_wrong_profile_data(self): + try: + Account.objects.create_user(username="jdoe", phone_number="NaN") + except: ## This exception will be throw as phone_number is not correct + pass + with self.assertRaises(Account.DoesNotExist): + Account.objects.get(username="jdoe") + + def test_create_user_KO_if_username_empty(self): + data = self.__modify_data( + {"username": "", "phone_number": 987654321, "email": "jdoe@mail.com"} + ) + with self.assertRaises(ValueError): + Account.objects.create_user(**data) + + def test_create_superuser_OK(self): + data = self.__modify_data( + {"username": "jdoe", "phone_number": 987654321, "email": "jdoe@mail.com"} + ) ## Update initial data, otherwise the unique fields will throw an exception + admin_user = Account.objects.create_superuser(**data) + self.assertTrue(admin_user.is_staff) + self.assertTrue(admin_user.is_superuser) + + def test_create_superuser_KO_if_is_staff_manually_set_to_False_KO(self): + data = self.__modify_data( + {"username": "jdoe", "phone_number": 987654321, "email": "jdoe@mail.com"} + ) + with self.assertRaises(ValueError): + Account.objects.create_superuser(is_staff=False, **data) + + def test_create_superuser_KO_if_is_superuser_manually_set_to_False_KO(self): + data = self.__modify_data( + {"username": "jdoe", "phone_number": 987654321, "email": "jdoe@mail.com"} + ) + with self.assertRaises(ValueError): + Account.objects.create_superuser(is_superuser=False, **data) + + ## UPDATE TESTS + + def test_update_account_OK(self): + self.account.refresh_from_db() ## Note that the update function updates the in-memory object at the same time it updates the ddbb, so as we are using always the same self.account object, we can have an in-memory object with values corresponding to other tests at this point, so we have to refresh it + self.account.update( + username="jdoe", + first_name="Johnny", + email="jdoe@mail.com", + phone_number=987654321, + ) + ## Updated fields + self.assertEqual(self.account.username, "jdoe") + self.assertEqual(self.account.email, "jdoe@mail.com") + self.assertEqual(self.account.profile.first_name, "Johnny") + self.assertEqual(self.account.profile.phone_number, 987654321) + ## The others remain equal, eg, the last_name + self.assertEqual(self.account.profile.last_name, self.initial_data["last_name"]) + + def test_update_without_email_OK(self): + self.account.refresh_from_db() + self.account.update(username="jdoe") + ## Updated fields + self.assertEqual(self.account.username, "jdoe") + + def test_update_without_username_OK(self): + self.account.refresh_from_db() + self.account.update(email="jdoe@mail.com") + ## Updated fields + self.assertEqual(self.account.email, "jdoe@mail.com") + + def test_update_rollback_OK_if_wrong_profile_data(self): + self.account.refresh_from_db() + try: + self.account.update(email="jdoe@mail.com", phone_number="onetwothree") + except: ## The previous line will throw an exception, it's OK, we want to check that the email and the phone_number doesn't change + pass + self.assertEqual(self.account.email, self.initial_data["email"]) + self.assertEqual( + self.account.profile.phone_number, self.initial_data["phone_number"] + ) + + def test_update_user_KO_if_not_username(self): + with self.assertRaises(ValueError): + self.account.update(username="") + + def test_update_user_KO_if_not_email(self): + with self.assertRaises(ValueError): + self.account.update(email="") + + def test_update_user_KO_if_wrong_data(self): + data = self.__modify_data( + {"username": "jdoe", "phone_number": 987654321, "email": "jdoe@mail.com"} + ) + Account.objects.create_user(**data) + with self.assertRaises(IntegrityError): + self.account.update(username="jdoe") + self.assertEqual( + self.account.username, self.initial_data["username"] + ) ## Test that the in-memory object is neither updated + + ## WITH_PERM TESTS + + def test_manager_with_perm_OK(self): + self.account.update(is_active=True) ## We need an active user for this test + users_with_perm = Account.objects.with_perm(self.permission) + self.assertIn(self.account, users_with_perm) + + # Test with_perm with invalid permission + users_without_perm = Account.objects.with_perm("auth.cannot_do_something") + self.assertNotIn(self.account, users_without_perm) + + # Test get the permission if backend manually supplied + users_with_perm = Account.objects.with_perm( + self.permission, backend="django.contrib.auth.backends.ModelBackend" + ) + self.assertIn(self.account, users_with_perm) + + # Test None if the backend doesn't have with_perm attribute + users_with_perm = Account.objects.with_perm( + self.permission, + backend="extended_accounts.models.tests.test_account.UselessBackend", + ) + self.assertNotIn(self.account, users_without_perm) + + def test_manager_with_perm_KO_if_multiple_backends(self): + # Mocking multiple authentication backends + with self.settings( + AUTHENTICATION_BACKENDS=[ + "django.contrib.auth.backends.ModelBackend", + "django.contrib.auth.backends.RemoteUserBackend", + ] + ): + with self.assertRaises(ValueError): + Account.objects.with_perm(self.permission) + + def test_manager_with_perm_KO_if_backend_not_string_not_None(self): + with self.assertRaises(TypeError): + Account.objects.with_perm(self.permission, backend=1) diff --git a/extended_accounts/models/tests/test_profile.py b/extended_accounts/models/tests/test_profile.py new file mode 100644 index 0000000..f185871 --- /dev/null +++ b/extended_accounts/models/tests/test_profile.py @@ -0,0 +1,41 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings +from extended_accounts.models import AccountModel as Account +from PIL import Image +from io import BytesIO +import tempfile, shutil, re + +MEDIA_ROOT = tempfile.mkdtemp() + + +def create_test_image(): + image_buffer = BytesIO() + image_object = Image.new("RGB", (1, 1)) + image_object.save(image_buffer, "png") + image_buffer.seek(0) + image = SimpleUploadedFile( + "test_image.png", + image_buffer.read(), + ) + return image + + +@override_settings(MEDIA_ROOT=MEDIA_ROOT) +class ProfileModelTestCase(TestCase): + @classmethod + def tearDownClass(cls, *args, **kwargs): + shutil.rmtree(MEDIA_ROOT, ignore_errors=True) + super().tearDownClass(*args, **kwargs) + + def test_image_uses_uuid_name( + self, + ): ## The ProfileModel is never accessed directly, it always goes with an AccountModel instance, so we do follow that here. Just checks that the image is uploaded with a hex name + account = Account.objects.create_user( + username="johndoe", + phone_number=123456789, + profile_image=create_test_image(), + ) + hex_name = re.compile( + r"^[0-9a-f]+$" + ) ##post_save signal removes the extension, so the image name should simply be a chunk of hexadecimal characters + self.assertTrue(hex_name.match(account.profile.profile_image.name)) diff --git a/extended_accounts/signals/__init__.py b/extended_accounts/signals/__init__.py new file mode 100644 index 0000000..56945d9 --- /dev/null +++ b/extended_accounts/signals/__init__.py @@ -0,0 +1,11 @@ +from .post_save_account_model import post_save_account_model +from .pre_save_profile_model import pre_save_profile_model +from .post_save_profile_model import post_save_profile_model +from .post_delete_profile_model import post_delete_profile_model + +__all__ = [ + "post_save_account_model", + "pre_save_profile_model", + "post_save_profile_model", + "post_delete_profile_model", +] diff --git a/extended_accounts/signals/post_delete_profile_model.py b/extended_accounts/signals/post_delete_profile_model.py new file mode 100644 index 0000000..0cbfd28 --- /dev/null +++ b/extended_accounts/signals/post_delete_profile_model.py @@ -0,0 +1,25 @@ +from django.db.models.signals import post_delete +from django.dispatch import receiver +from django.conf import settings +from extended_accounts.models import ProfileModel as Profile +import os + + +def delete_profile_image(instance): + profile_image = instance.profile_image + if profile_image.name: + list_of_images = filter( + lambda image: profile_image.name in image, + os.listdir(settings.MEDIA_ROOT), + ) + for image in list_of_images: + try: + os.remove(os.path.join(settings.MEDIA_ROOT, image)) + except: + pass + + +@receiver(post_delete, sender=Profile) +def post_delete_profile_model(sender, **kwargs): + instance = kwargs["instance"] + delete_profile_image(instance) diff --git a/extended_accounts/signals/post_save_account_model.py b/extended_accounts/signals/post_save_account_model.py new file mode 100644 index 0000000..1c98062 --- /dev/null +++ b/extended_accounts/signals/post_save_account_model.py @@ -0,0 +1,23 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.conf import settings +from extended_accounts.models import AccountModel as Account +from extended_accounts.helpers import delete_unconfirmed_accounts + + +def trigger_delete_unconfirmed_accounts(instance): + if ( + settings.TESTING ^ settings.INTEGRATION_TEST_CELERY + ): ## If we are running tests, we don't launch the Celery task every time a user is created. We don't want to test Celery (external dependency) but our functionality. We only launch it in case we are testing the Celery integration + return + delete_unconfirmed_accounts.apply_async( + args=[instance.username], + countdown=900, ## 900 seconds = 15 minutes + ) + + +@receiver(post_save, sender=Account) +def post_save_account_model(sender, **kwargs): + instance = kwargs["instance"] + if kwargs["created"]: + trigger_delete_unconfirmed_accounts(instance) diff --git a/extended_accounts/signals/post_save_profile_model.py b/extended_accounts/signals/post_save_profile_model.py new file mode 100644 index 0000000..666bf41 --- /dev/null +++ b/extended_accounts/signals/post_save_profile_model.py @@ -0,0 +1,24 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from extended_accounts.models import ProfileModel as Profile +from PIL import Image + + +def manage_uploaded_image(instance): + profile_image = instance.profile_image + if ( + profile_image and "." in profile_image.name + ): ## If '.' in profile_image.name, it means the image has been updated since in the database the name is stored without extension + ## We save the image in webp format on the server. + path = profile_image.path + server_image = Image.open(profile_image) + server_image.save(f'{path.split(".")[0]}.webp', format="WEBP") + ## We save the name without extension in the database + profile_image.name = profile_image.name.split(".")[0] + instance.save() + + +@receiver(post_save, sender=Profile) +def post_save_profile_model(sender, **kwargs): + instance = kwargs["instance"] + manage_uploaded_image(instance) diff --git a/extended_accounts/signals/pre_save_profile_model.py b/extended_accounts/signals/pre_save_profile_model.py new file mode 100644 index 0000000..f7a5464 --- /dev/null +++ b/extended_accounts/signals/pre_save_profile_model.py @@ -0,0 +1,37 @@ +from django.db.models.signals import pre_save +from django.dispatch import receiver +from django.conf import settings +from extended_accounts.models import ProfileModel as Profile +import os + + +def delete_previous_image_if_needed(instance): + profile_image = instance.profile_image + try: + original_instance = Profile.objects.get(pk=instance.pk) + except Profile.DoesNotExist: + pass + else: + conditions = [ + profile_image.name + and profile_image.name + not in original_instance.profile_image.name, ## Change image condition: Be careful because the post_save signal makes a save to remove the extension from the image name in the ddbb, if we use != here instead of not in, we delete the newly uploaded image since profile_image.name is the name without the extension and the original one (in this case what was saved initially when calling save) is the name with the extension + not profile_image.name + and original_instance.profile_image.name, ## User's image deletion condition (the original instance has content but not the new one) + ] + if any(conditions): + list_of_images = filter( + lambda image: original_instance.profile_image.name in image, + os.listdir(settings.MEDIA_ROOT), + ) + for image in list_of_images: + try: + os.remove(os.path.join(settings.MEDIA_ROOT, image)) + except: + pass + + +@receiver(pre_save, sender=Profile) +def pre_save_profile_model(sender, **kwargs): + instance = kwargs["instance"] + delete_previous_image_if_needed(instance) diff --git a/extended_accounts/signals/tests/__init__.py b/extended_accounts/signals/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/extended_accounts/signals/tests/test_post_delete_profile_model.py b/extended_accounts/signals/tests/test_post_delete_profile_model.py new file mode 100644 index 0000000..ebbe910 --- /dev/null +++ b/extended_accounts/signals/tests/test_post_delete_profile_model.py @@ -0,0 +1,52 @@ +from django.test import TestCase, override_settings +from django.core.files.uploadedfile import SimpleUploadedFile +from extended_accounts.models import AccountModel as Account +from PIL import Image +from io import BytesIO +from unittest.mock import patch +import tempfile, shutil, os + +MEDIA_ROOT = tempfile.mkdtemp() + + +def create_test_image(): + image_buffer = BytesIO() + image_object = Image.new("RGB", (1, 1)) + image_object.save(image_buffer, "png") + image_buffer.seek(0) + image = SimpleUploadedFile( + "test_image.png", + image_buffer.read(), + ) + return image + + +@override_settings(MEDIA_ROOT=MEDIA_ROOT) +class PostDeleteProfileModelTestCase( + TestCase +): ## Profile is a model completely linked to Account, so we can test its features through an account instance + def setUp(self): + self.account = Account.objects.create_user( + username="johndoe", + email="johndoe@mail.com", + phone_number=123456789, + profile_image=create_test_image(), + ) + + @classmethod + def tearDownClass(cls, *args, **kwargs): + shutil.rmtree(MEDIA_ROOT, ignore_errors=True) + super().tearDownClass(*args, **kwargs) + + def test_post_delete_model_profile(self): + previous_image_name = self.account.profile.profile_image.name + self.assertIn(previous_image_name + ".png", os.listdir(MEDIA_ROOT)) + self.assertIn(previous_image_name + ".webp", os.listdir(MEDIA_ROOT)) + self.account.delete() + self.assertNotIn(previous_image_name + ".png", os.listdir(MEDIA_ROOT)) + self.assertNotIn(previous_image_name + ".webp", os.listdir(MEDIA_ROOT)) + + @patch("os.remove") + def test_non_blocking_execution_if_remove_nonexistent_image(self, mock_os_remove): + mock_os_remove.side_effect = Exception("Simulated exception") + self.account.delete() diff --git a/extended_accounts/signals/tests/test_post_save_profile_model.py b/extended_accounts/signals/tests/test_post_save_profile_model.py new file mode 100644 index 0000000..3d2b77f --- /dev/null +++ b/extended_accounts/signals/tests/test_post_save_profile_model.py @@ -0,0 +1,55 @@ +from django.test import TestCase, override_settings +from django.core.files.uploadedfile import SimpleUploadedFile +from extended_accounts.models import AccountModel as Account +from PIL import Image +from io import BytesIO +import tempfile, shutil, re, os + +MEDIA_ROOT = ( + tempfile.mkdtemp() +) ## During the tests, we will use this directory for image uploads + + +def create_test_image(): + image_buffer = BytesIO() + image_object = Image.new("RGB", (1, 1)) + image_object.save(image_buffer, "png") + image_buffer.seek(0) + image = SimpleUploadedFile( + "test_image.png", + image_buffer.read(), + ) + return image + + +@override_settings(MEDIA_ROOT=MEDIA_ROOT) +class PostSaveProfileModelTesCase(TestCase): + + @classmethod + def tearDownClass(cls, *args, **kwargs): + shutil.rmtree( + MEDIA_ROOT, ignore_errors=True + ) ## At the end of the tests, the temporary directory is removed. ignore_errors = True ensures that this will not cause any issues; we are indifferent to errors during deletion. + super().tearDownClass(*args, **kwargs) + + def test_post_save_model_perfil( + self, + ): ## Profile is a model completely linked to Account, so we can test its features through an account instance + account = Account.objects.create_user( + username="johndoe", + email="johndoe@mail.com", + phone_number=123456789, + profile_image=create_test_image(), + ) + unique_name_without_extension = re.compile(r"^[0-9a-f]+$") + self.assertTrue( + unique_name_without_extension.match(account.profile.profile_image.name) + ) ## In the database, we only have the name without extensions + ## But the image has been successfully uploaded to the server + self.assertIn(MEDIA_ROOT, account.profile.profile_image.path) + self.assertIn( + account.profile.profile_image.name + ".png", os.listdir(MEDIA_ROOT) + ) + self.assertIn( + account.profile.profile_image.name + ".webp", os.listdir(MEDIA_ROOT) + ) diff --git a/extended_accounts/signals/tests/test_pre_save_profile_model.py b/extended_accounts/signals/tests/test_pre_save_profile_model.py new file mode 100644 index 0000000..d07ca36 --- /dev/null +++ b/extended_accounts/signals/tests/test_pre_save_profile_model.py @@ -0,0 +1,61 @@ +from django.test import TestCase, override_settings +from django.core.files.uploadedfile import SimpleUploadedFile +from extended_accounts.models import AccountModel as Account +from PIL import Image +from io import BytesIO +from unittest.mock import patch +import tempfile, shutil, os + +MEDIA_ROOT = tempfile.mkdtemp() + + +def create_test_image(): + image_buffer = BytesIO() + image_object = Image.new("RGB", (1, 1)) + image_object.save(image_buffer, "png") + image_buffer.seek(0) + image = SimpleUploadedFile( + "test_image.png", + image_buffer.read(), + ) + return image + + +@override_settings(MEDIA_ROOT=MEDIA_ROOT) +class PreSaveProfileModelTestCase(TestCase): + def setUp( + self, + ): ## The tests for deleting images can decouple the storage, it's better that each one has its own setup + self.account = Account.objects.create_user( + username="johndoe", + phone_number=123456789, + email="johndoe@mail.com", + profile_image=create_test_image(), + ) ## Profile is a model completely linked to Account, so we can test its features through an account instance + + @classmethod + def tearDownClass(cls, *args, **kwargs): + shutil.rmtree(MEDIA_ROOT, ignore_errors=True) + super().tearDownClass(*args, **kwargs) + + def test_pre_save_model_profile(self): + ## Test previous image deletion if a new one is saved + previous_image_name = self.account.profile.profile_image.name + self.assertIn(previous_image_name + ".png", os.listdir(MEDIA_ROOT)) + self.assertIn(previous_image_name + ".webp", os.listdir(MEDIA_ROOT)) + self.account.update(profile_image=create_test_image()) + self.assertNotIn(previous_image_name + ".png", os.listdir(MEDIA_ROOT)) + self.assertNotIn(previous_image_name + ".webp", os.listdir(MEDIA_ROOT)) + + ## Test previous image deletion if the image is deleted + previous_image_name = self.account.profile.profile_image.name + self.assertIn(previous_image_name + ".png", os.listdir(MEDIA_ROOT)) + self.assertIn(previous_image_name + ".webp", os.listdir(MEDIA_ROOT)) + self.account.update(profile_image=None) + self.assertNotIn(previous_image_name + ".png", os.listdir(MEDIA_ROOT)) + self.assertNotIn(previous_image_name + ".webp", os.listdir(MEDIA_ROOT)) + + @patch("os.remove") + def test_non_blocking_execution_if_remove_nonexistent_image(self, mock_os_remove): + mock_os_remove.side_effect = Exception("Simulated exception") + self.account.update(profile_image=create_test_image()) diff --git a/extended_accounts/templates/base.html b/extended_accounts/templates/base.html new file mode 100644 index 0000000..77fdcca --- /dev/null +++ b/extended_accounts/templates/base.html @@ -0,0 +1,22 @@ + + + + + + + + {% block title %}{% endblock %} + + +
+
+ +
+ {% block content %} + {% endblock %} +
+ + + + \ No newline at end of file diff --git a/extended_accounts/templates/extended_accounts/delete_account.html b/extended_accounts/templates/extended_accounts/delete_account.html new file mode 100644 index 0000000..e2f66b4 --- /dev/null +++ b/extended_accounts/templates/extended_accounts/delete_account.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} + +{% block title %} + Delete account +{% endblock title %} + +{% block content %} +
+ {% csrf_token %} + +
+{% endblock content %} \ No newline at end of file diff --git a/extended_accounts/templates/extended_accounts/detail_account.html b/extended_accounts/templates/extended_accounts/detail_account.html new file mode 100644 index 0000000..0600628 --- /dev/null +++ b/extended_accounts/templates/extended_accounts/detail_account.html @@ -0,0 +1,22 @@ +{% extends 'base.html' %} + +{% block title %} +Account +{% endblock %} + +{% block content %} + + {% if object.profile.profile_image %} + + + image + + {% endif %} + +{% endblock %} diff --git a/extended_accounts/templates/extended_accounts/list_account.html b/extended_accounts/templates/extended_accounts/list_account.html new file mode 100644 index 0000000..8514a9b --- /dev/null +++ b/extended_accounts/templates/extended_accounts/list_account.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block title %} +Account +{% endblock %} + +{% block content %} + + + +{% endblock %} diff --git a/extended_accounts/templates/extended_accounts/login.html b/extended_accounts/templates/extended_accounts/login.html new file mode 100644 index 0000000..49a1662 --- /dev/null +++ b/extended_accounts/templates/extended_accounts/login.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block title %} +Inicio de sesión +{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {{ form.as_p }} + +
+ Not registered yet? Create an account + Did you forget your password? +{% endblock %} diff --git a/extended_accounts/templates/extended_accounts/new_account.html b/extended_accounts/templates/extended_accounts/new_account.html new file mode 100644 index 0000000..2c1b8d9 --- /dev/null +++ b/extended_accounts/templates/extended_accounts/new_account.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} + +{% block title %} + New account +{% endblock title %} + +{% block content %} +
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock content %} \ No newline at end of file diff --git a/extended_accounts/templates/extended_accounts/password_change_form.html b/extended_accounts/templates/extended_accounts/password_change_form.html new file mode 100644 index 0000000..0f4fb07 --- /dev/null +++ b/extended_accounts/templates/extended_accounts/password_change_form.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} + +{% block title %} + Change your password +{% endblock title %} + +{% block content %} +
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock content %} \ No newline at end of file diff --git a/extended_accounts/templates/extended_accounts/password_reset_confirm.html b/extended_accounts/templates/extended_accounts/password_reset_confirm.html new file mode 100644 index 0000000..c0ec3f1 --- /dev/null +++ b/extended_accounts/templates/extended_accounts/password_reset_confirm.html @@ -0,0 +1,18 @@ +{% extends 'base.html' %} + +{% block title %} + Reset password +{% endblock title %} + +{% block content %} + {% if validlink %} +
+ {% csrf_token %} + {{ form.as_p }} + +
+ {% else %} +

This link is not longer valid. Please, request a new one if you need to reset your password

+ {% endif %} + +{% endblock content %} \ No newline at end of file diff --git a/extended_accounts/templates/extended_accounts/password_reset_done.html b/extended_accounts/templates/extended_accounts/password_reset_done.html new file mode 100644 index 0000000..d3d6a39 --- /dev/null +++ b/extended_accounts/templates/extended_accounts/password_reset_done.html @@ -0,0 +1,9 @@ +{% extends 'base.html' %} + +{% block title %} + Password reset request succeeded +{% endblock title %} + +{% block content %} +

Thanks! We have sent instructions to your e-mail to reset your password

+{% endblock content %} \ No newline at end of file diff --git a/extended_accounts/templates/extended_accounts/password_reset_email.html b/extended_accounts/templates/extended_accounts/password_reset_email.html new file mode 100644 index 0000000..ed8aed0 --- /dev/null +++ b/extended_accounts/templates/extended_accounts/password_reset_email.html @@ -0,0 +1,2 @@ +New password requested for {{ user }}. : +{{ protocol}}://{{ domain }}{% url 'extended_accounts:reset_password' uidb64=uid token=token %} \ No newline at end of file diff --git a/extended_accounts/templates/extended_accounts/password_reset_form.html b/extended_accounts/templates/extended_accounts/password_reset_form.html new file mode 100644 index 0000000..bac3046 --- /dev/null +++ b/extended_accounts/templates/extended_accounts/password_reset_form.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} + +{% block title %} + Reset Password Request +{% endblock title %} + +{% block content %} +
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock content %} \ No newline at end of file diff --git a/extended_accounts/templates/extended_accounts/password_reset_subject.html b/extended_accounts/templates/extended_accounts/password_reset_subject.html new file mode 100644 index 0000000..0eafde8 --- /dev/null +++ b/extended_accounts/templates/extended_accounts/password_reset_subject.html @@ -0,0 +1 @@ +Password reset requested \ No newline at end of file diff --git a/extended_accounts/templates/extended_accounts/update_account.html b/extended_accounts/templates/extended_accounts/update_account.html new file mode 100644 index 0000000..24c27ff --- /dev/null +++ b/extended_accounts/templates/extended_accounts/update_account.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} + +{% block title %} + Update account +{% endblock title %} + +{% block content %} +
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock content %} \ No newline at end of file diff --git a/extended_accounts/urls.py b/extended_accounts/urls.py new file mode 100644 index 0000000..fd08add --- /dev/null +++ b/extended_accounts/urls.py @@ -0,0 +1,86 @@ +from django.urls import path, reverse_lazy +from django.contrib.auth import views as auth_views +from django.conf import settings +from extended_accounts.views import ( + NewAccountView, + AccountConfirmationView, + DetailAccountView, + UpdateAccountView, + DeleteAccountView, + RedirectAccountView, + ListAccountView, + DeleteProfileImageView, +) + +app_name = "extended_accounts" +urlpatterns = [ + path("new_account/", NewAccountView.as_view(), name="new_account"), + path( + "account_confirmation///", + AccountConfirmationView.as_view(), + name="account_confirmation", + ), + path( + "login/", + auth_views.LoginView.as_view(template_name="extended_accounts/login.html"), + name="login", + ), + path("logout/", auth_views.LogoutView.as_view(), name="logout"), + path( + "detail_account//", + DetailAccountView.as_view(), + name="detail_account", + ), + path("list_account/", ListAccountView.as_view(), name="list_account"), + path( + "password_change/", + auth_views.PasswordChangeView.as_view( + success_url=reverse_lazy("extended_accounts:redirect_account"), + template_name="extended_accounts/password_change_form.html", + ), + name="password_change", + ), + path( + "reset_password_request/", + auth_views.PasswordResetView.as_view( + success_url=reverse_lazy("extended_accounts:reset_password_request_done"), + template_name="extended_accounts/password_reset_form.html", + email_template_name="extended_accounts/password_reset_email.html", + subject_template_name="extended_accounts/password_reset_subject.html", + from_email=settings.DEFAULT_FROM_EMAIL, + ), + name="reset_password_request", + ), + path( + "reset_password_request_done/", + auth_views.PasswordResetDoneView.as_view( + template_name="extended_accounts/password_reset_done.html" + ), + name="reset_password_request_done", + ), + path( + "reset_password///", + auth_views.PasswordResetConfirmView.as_view( + success_url=reverse_lazy("extended_accounts:redirect_account"), + post_reset_login=True, + template_name="extended_accounts/password_reset_confirm.html", + ), + name="reset_password", + ), + path( + "update_account/", + UpdateAccountView.as_view(), + name="update_account", + ), + path( + "delete_account//", + DeleteAccountView.as_view(), + name="delete_account", + ), + path( + "delete_profile_image//", + DeleteProfileImageView.as_view(), + name="delete_profile_image", + ), + path("redirect_account/", RedirectAccountView.as_view(), name="redirect_account"), +] diff --git a/extended_accounts/views/AccountConfirmation.py b/extended_accounts/views/AccountConfirmation.py new file mode 100644 index 0000000..e3158eb --- /dev/null +++ b/extended_accounts/views/AccountConfirmation.py @@ -0,0 +1,25 @@ +from django.urls import reverse_lazy +from django.shortcuts import get_object_or_404 +from django.http import HttpResponseRedirect, Http404 +from django.views.generic import View +from django.contrib.auth import login +from django.contrib.auth.tokens import default_token_generator +from extended_accounts.models import AccountModel as Account + + +class AccountConfirmationView(View): + def get(self, request, **kwargs): + ## If the account does not exist or it is already validated, we return a 404. + ## We don't want this URL to be visited more than once per user. + ## If everything is OK, we activate the account and redirect. + account = get_object_or_404(Account, username=kwargs["username"]) + if account.is_active: + raise Http404 + if default_token_generator.check_token(account, kwargs["token"]): + account.is_active = True + account.save() + login(request, account) + return HttpResponseRedirect( + reverse_lazy("extended_accounts:redirect_account") + ) + raise Http404 diff --git a/extended_accounts/views/DeleteAccount.py b/extended_accounts/views/DeleteAccount.py new file mode 100644 index 0000000..9b01a8e --- /dev/null +++ b/extended_accounts/views/DeleteAccount.py @@ -0,0 +1,23 @@ +from django.views.generic import DeleteView +from django.shortcuts import get_object_or_404 +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.urls import reverse_lazy +from django.http import Http404 +from extended_accounts.models import AccountModel as Account + + +class DeleteAccountView(LoginRequiredMixin, UserPassesTestMixin, DeleteView): + model = Account + template_name = "extended_accounts/delete_account.html" + + def get_success_url(self): + return reverse_lazy("extended_accounts:login") + + def get_object(self): + return get_object_or_404(Account, username=self.kwargs["username"]) + + def test_func(self): + account = self.get_object() + if account != self.request.user: + raise Http404 + return True diff --git a/extended_accounts/views/DeleteProfileImage.py b/extended_accounts/views/DeleteProfileImage.py new file mode 100644 index 0000000..7f87a96 --- /dev/null +++ b/extended_accounts/views/DeleteProfileImage.py @@ -0,0 +1,23 @@ +from django.views.generic import View +from django.shortcuts import get_object_or_404 +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.urls import reverse_lazy +from django.http import Http404, HttpResponseRedirect +from extended_accounts.models import AccountModel as Account + + +class DeleteProfileImageView(LoginRequiredMixin, UserPassesTestMixin, View): + + def get_object(self): + return get_object_or_404(Account, username=self.kwargs["username"]) + + def post(self, *args, **kwargs): + account = self.get_object() + account.update(profile_image=None) + return HttpResponseRedirect(reverse_lazy("extended_accounts:redirect_account")) + + def test_func(self): + account = self.get_object() + if account != self.request.user: + raise Http404 + return True diff --git a/extended_accounts/views/DetailAccount.py b/extended_accounts/views/DetailAccount.py new file mode 100644 index 0000000..c4fef2e --- /dev/null +++ b/extended_accounts/views/DetailAccount.py @@ -0,0 +1,13 @@ +from django.views.generic import DetailView +from django.shortcuts import get_object_or_404 +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import Http404 +from extended_accounts.models import AccountModel as Account + + +class DetailAccountView(LoginRequiredMixin, DetailView): + template_name = "extended_accounts/detail_account.html" + + def get_object(self): + account = get_object_or_404(Account, username=self.kwargs["username"]) + return account diff --git a/extended_accounts/views/ListAccount.py b/extended_accounts/views/ListAccount.py new file mode 100644 index 0000000..3f3c937 --- /dev/null +++ b/extended_accounts/views/ListAccount.py @@ -0,0 +1,8 @@ +from django.views.generic import ListView +from django.contrib.auth.mixins import LoginRequiredMixin +from extended_accounts.models import AccountModel as Account + + +class ListAccountView(LoginRequiredMixin, ListView): + template_name = "extended_accounts/list_account.html" + model = Account diff --git a/extended_accounts/views/NewAccount.py b/extended_accounts/views/NewAccount.py new file mode 100644 index 0000000..a370c7b --- /dev/null +++ b/extended_accounts/views/NewAccount.py @@ -0,0 +1,34 @@ +from django.urls import reverse_lazy +from django.core.mail import send_mail +from django.views.generic.edit import CreateView +from django.contrib.auth.tokens import default_token_generator +from django.conf import settings +from extended_accounts.helpers import NewAccountForm +from extended_accounts.models import AccountModel as Account + + +class NewAccountView(CreateView): + template_name = "extended_accounts/new_account.html" + form_class = NewAccountForm + + def get_success_url(self): + return reverse_lazy("extended_accounts:login") + + def form_valid(self, form): + ## Get the response of the super's form_valid + response = super().form_valid(form) + account = Account.objects.get( + username=form.instance.username + ) ## Get the account directly from the model manager. If we use form.instance directly, it doesn't have the 1to1 related Profile object because it's just a form object + self.__send_confirmation_email(account) + return response + + def __send_confirmation_email(self, account): + subject = "Account Confirmation" + message = f'Hello!\nWe have received your account creation request, follow the link {self.request.build_absolute_uri(reverse_lazy("extended_accounts:account_confirmation", kwargs = {"username": account.username, "token": default_token_generator.make_token(account)}))} to confirm your account. The link will be valid for 15 minutes, if you do not confirm the account within that time frame you will have to start the process again.' + send_mail( + subject=subject, + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[account.email], + ) diff --git a/extended_accounts/views/RedirectAccount.py b/extended_accounts/views/RedirectAccount.py new file mode 100644 index 0000000..a1b15d9 --- /dev/null +++ b/extended_accounts/views/RedirectAccount.py @@ -0,0 +1,14 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic import View +from django.urls import reverse_lazy +from django.http import HttpResponseRedirect + + +class RedirectAccountView(LoginRequiredMixin, View): + def get(self, request): + return HttpResponseRedirect( + reverse_lazy( + "extended_accounts:detail_account", + kwargs={"username": request.user.username}, + ) + ) diff --git a/extended_accounts/views/UpdateAccount.py b/extended_accounts/views/UpdateAccount.py new file mode 100644 index 0000000..d7c2c40 --- /dev/null +++ b/extended_accounts/views/UpdateAccount.py @@ -0,0 +1,37 @@ +from django.db.models.base import Model as Model +from django.urls import reverse_lazy +from django.views.generic.edit import UpdateView +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from extended_accounts.models import AccountModel as Account +from extended_accounts.helpers import UpdateAccountForm + + +class UpdateAccountView(LoginRequiredMixin, UserPassesTestMixin, UpdateView): + template_name = "extended_accounts/update_account.html" + model = Account + form_class = UpdateAccountForm + + def get_success_url(self): + return reverse_lazy("extended_accounts:redirect_account") + + def get_object(self): + return get_object_or_404(Account, username=self.kwargs["username"]) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + instance = kwargs["instance"] + kwargs["initial"] = { + "first_name": instance.profile.first_name, + "last_name": instance.profile.last_name, + "phone_number": instance.profile.phone_number, + "profile_image": instance.profile.profile_image, + } + return kwargs + + def test_func(self): + user = self.get_object() + if user != self.request.user: + raise Http404 + return True diff --git a/extended_accounts/views/__init__.py b/extended_accounts/views/__init__.py new file mode 100644 index 0000000..adb4f4d --- /dev/null +++ b/extended_accounts/views/__init__.py @@ -0,0 +1,8 @@ +from .DetailAccount import DetailAccountView +from .NewAccount import NewAccountView +from .RedirectAccount import RedirectAccountView +from .AccountConfirmation import AccountConfirmationView +from .UpdateAccount import UpdateAccountView +from .ListAccount import ListAccountView +from .DeleteAccount import DeleteAccountView +from .DeleteProfileImage import DeleteProfileImageView diff --git a/extended_accounts/views/tests/__init__.py b/extended_accounts/views/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/extended_accounts/views/tests/test_account_confirmation.py b/extended_accounts/views/tests/test_account_confirmation.py new file mode 100644 index 0000000..8052ef2 --- /dev/null +++ b/extended_accounts/views/tests/test_account_confirmation.py @@ -0,0 +1,96 @@ +from django.test import TestCase, RequestFactory +from django.urls import reverse_lazy +from django.contrib.auth.tokens import default_token_generator +from django.contrib.sessions.middleware import SessionMiddleware +from django.http import Http404 +from extended_accounts.models import AccountModel as Account +from extended_accounts.views import AccountConfirmationView + + +class AccountConfirmationViewTestCase(TestCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.account = Account.objects.create_user( + username="johndoe", email="johndoe@mail.com", phone_number=123456789 + ) + cls.factory = RequestFactory() + cls.token = default_token_generator.make_token(cls.account) + + def test_ok(self): + """ + Test that if the user is not active and the followed link is the activation one, then the account gets confirmed and redirected (HTTP response code = 302) + """ + request = self.factory.get( + reverse_lazy( + "extended_accounts:account_confirmation", + kwargs={ + "username": self.account.username, + "token": self.token, + }, + ) + ) + ## Since the user has is_active set to False, request.user = self.account doesn't assign a session to the request and the response cannot be processed, hence we need to create a fake session with SessionMiddleware, which creates the session without doing anything else (its get_response function does nothing) + middleware = SessionMiddleware(lambda get_response: None) + middleware.process_request(request) + request.session.save() + response = AccountConfirmationView.as_view()( + request, username=self.account.username, token=self.token + ) + self.assertEqual(302, response.status_code) + self.assertEqual( + response.url, + reverse_lazy("extended_accounts:redirect_account"), + ) + ## Check the user's now active + self.account.refresh_from_db() + self.assertTrue(self.account.is_active) + + def test_user_not_found_404(self): + """ + Test that if the user passed by URL doesn't exist, then a 404 is rendered + """ + request = self.factory.get( + reverse_lazy( + "extended_accounts:account_confirmation", + kwargs={"username": "jdoe", "token": "doesntmatter"}, + ) + ) + with self.assertRaises(Http404): + AccountConfirmationView.as_view()( + request, username=self.account.username, token="doesntmatter" + ) + + def test_active_user_404(self): + """ + Test that if the user passed by URL is already active, then a 404 is rendered + """ + self.account.update(is_active=True) + request = self.factory.get( + reverse_lazy( + "extended_accounts:account_confirmation", + kwargs={ + "username": self.account.username, + "token": self.token, + }, + ) + ) + with self.assertRaises(Http404): + AccountConfirmationView.as_view()( + request, username=self.account.username, token=self.token + ) + + def test_wrong_token_404(self): + """ + Test that if the token is incorrect, it responds with 404 + """ + request = self.factory.get( + reverse_lazy( + "extended_accounts:account_confirmation", + kwargs={"username": self.account.username, "token": "wrong_token"}, + ) + ) + with self.assertRaises(Http404): + AccountConfirmationView.as_view()( + request, username=self.account.username, token="wrong_token" + ) diff --git a/extended_accounts/views/tests/test_delete_account.py b/extended_accounts/views/tests/test_delete_account.py new file mode 100644 index 0000000..0849bfe --- /dev/null +++ b/extended_accounts/views/tests/test_delete_account.py @@ -0,0 +1,93 @@ +from django.test import TestCase, RequestFactory, override_settings +from django.urls import reverse_lazy +from django.http import Http404 +from django.core.files.uploadedfile import SimpleUploadedFile +from extended_accounts.models import AccountModel as Account +from extended_accounts.views import DeleteAccountView +from PIL import Image +from io import BytesIO +import tempfile, shutil, os + +MEDIA_ROOT = tempfile.mkdtemp() + + +def create_test_image(): + image_buffer = BytesIO() + image_object = Image.new("RGB", (1, 1)) + image_object.save(image_buffer, "png") + image_buffer.seek(0) + image = SimpleUploadedFile( + "test_image.png", + image_buffer.read(), + ) + return image + + +@override_settings(MEDIA_ROOT=MEDIA_ROOT) +class DeleteAccountViewTestCase(TestCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.account = Account.objects.create_user( + username="johndoe", + phone_number=123456789, + email="johndoe@mail.com", + profile_image=create_test_image(), + ) + cls.delete_url = reverse_lazy( + "extended_accounts:delete_account", + kwargs={"username": cls.account.username}, + ) + cls.factory = RequestFactory() + cls.view = DeleteAccountView() + + @classmethod + def tearDownClass(cls, *args, **kwargs): + shutil.rmtree( + MEDIA_ROOT, ignore_errors=True + ) ## At the end of the tests, the temporary directory is removed. ignore_errors = True ensures that this won't cause any issues, we don't care if this directory has errors when being removed + super().tearDownClass(*args, **kwargs) + + def test_account_deleted(self): + previous_image_name = self.account.profile.profile_image.name + self.assertIn(previous_image_name + ".png", os.listdir(MEDIA_ROOT)) + self.assertIn(previous_image_name + ".webp", os.listdir(MEDIA_ROOT)) + request = self.factory.post(self.delete_url) + request.user = self.account + response = DeleteAccountView.as_view()(request, username=self.account.username) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, reverse_lazy("extended_accounts:login")) + with self.assertRaises(Account.DoesNotExist): + Account.objects.get(username=self.account.username) + self.assertNotIn(previous_image_name + ".png", os.listdir(MEDIA_ROOT)) + self.assertNotIn(previous_image_name + ".webp", os.listdir(MEDIA_ROOT)) + + def test_get_success_url(self): + self.assertEqual( + self.view.get_success_url(), reverse_lazy("extended_accounts:login") + ) + + def test_get_object_correct(self): + self.view.kwargs = {"username": self.account.username} + self.assertEqual(self.view.get_object(), self.account) + + def test_get_object_404(self): + self.view.kwargs = {"username": "not_registered_user"} + with self.assertRaises(Http404): + self.view.get_object() + + def test_test_func_OK(self): + request = self.factory.get(self.delete_url) + request.user = self.account + self.view.setup(request, username=self.account.username) + self.assertTrue(self.view.test_func()) + + def test_test_func_404(self): + request = self.factory.get(self.delete_url) + other_account = Account.objects.create_user( + username="other", email="other@mail.com", phone_number=987654321 + ) + request.user = other_account + self.view.setup(request, username=self.account.username) + with self.assertRaises(Http404): + self.view.test_func() diff --git a/extended_accounts/views/tests/test_delete_profile_image.py b/extended_accounts/views/tests/test_delete_profile_image.py new file mode 100644 index 0000000..c80a82e --- /dev/null +++ b/extended_accounts/views/tests/test_delete_profile_image.py @@ -0,0 +1,90 @@ +from django.test import TestCase, RequestFactory, override_settings +from django.urls import reverse_lazy +from django.http import Http404 +from django.core.files.uploadedfile import SimpleUploadedFile +from extended_accounts.models import AccountModel as Account +from extended_accounts.views import DeleteProfileImageView +from PIL import Image +from io import BytesIO +import tempfile, shutil, os + +MEDIA_ROOT = tempfile.mkdtemp() + + +def create_test_image(): + image_buffer = BytesIO() + image_object = Image.new("RGB", (1, 1)) + image_object.save(image_buffer, "png") + image_buffer.seek(0) + image = SimpleUploadedFile( + "test_image.png", + image_buffer.read(), + ) + return image + + +@override_settings(MEDIA_ROOT=MEDIA_ROOT) +class DeleteProfileImageViewTestCase(TestCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.account = Account.objects.create_user( + username="johndoe", + phone_number=123456789, + email="johndoe@mail.com", + profile_image=create_test_image(), + ) + cls.delete_profile_image_url = reverse_lazy( + "extended_accounts:delete_profile_image", + kwargs={"username": cls.account.username}, + ) + cls.factory = RequestFactory() + cls.view = DeleteProfileImageView() + + @classmethod + def tearDownClass(cls, *args, **kwargs): + shutil.rmtree( + MEDIA_ROOT, ignore_errors=True + ) ## At the end of the tests, the temporary directory is removed. ignore_errors = True ensures that this won't cause any issues, we don't care if this directory has errors when being removed + super().tearDownClass(*args, **kwargs) + + def test_profile_image_deleted(self): + previous_image_name = self.account.profile.profile_image.name + self.assertIn(previous_image_name + ".png", os.listdir(MEDIA_ROOT)) + self.assertIn(previous_image_name + ".webp", os.listdir(MEDIA_ROOT)) + request = self.factory.post(self.delete_profile_image_url) + request.user = self.account + response = DeleteProfileImageView.as_view()( + request, username=self.account.username + ) + self.assertEqual(response.status_code, 302) + self.assertEqual( + response.url, reverse_lazy("extended_accounts:redirect_account") + ) + self.assertNotIn(previous_image_name + ".png", os.listdir(MEDIA_ROOT)) + self.assertNotIn(previous_image_name + ".webp", os.listdir(MEDIA_ROOT)) + + def test_get_object_correct(self): + self.view.kwargs = {"username": self.account.username} + self.assertEqual(self.view.get_object(), self.account) + + def test_get_object_404(self): + self.view.kwargs = {"username": "not_registered_user"} + with self.assertRaises(Http404): + self.view.get_object() + + def test_test_func_OK(self): + request = self.factory.get(self.delete_profile_image_url) + request.user = self.account + self.view.setup(request, username=self.account.username) + self.assertTrue(self.view.test_func()) + + def test_test_func_404(self): + request = self.factory.get(self.delete_profile_image_url) + other_account = Account.objects.create_user( + username="other", email="other@mail.com", phone_number=987654321 + ) + request.user = other_account + self.view.setup(request, username=self.account.username) + with self.assertRaises(Http404): + self.view.test_func() diff --git a/extended_accounts/views/tests/test_detail_account.py b/extended_accounts/views/tests/test_detail_account.py new file mode 100644 index 0000000..5d79f5b --- /dev/null +++ b/extended_accounts/views/tests/test_detail_account.py @@ -0,0 +1,29 @@ +from django.test import TestCase, RequestFactory +from django.urls import reverse_lazy +from django.http import Http404 +from extended_accounts.models import AccountModel as Account +from extended_accounts.views import DetailAccountView + + +class DetailAccountViewTestCase(TestCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.account = Account.objects.create_user( + username="johndoe", phone_number=123456789, email="johndoe@mail.com" + ) + cls.detail_url = reverse_lazy( + "extended_accounts:detail_account", + kwargs={"username": cls.account.username}, + ) + cls.view = DetailAccountView() + cls.factory = RequestFactory() + + def test_get_object_correct(self): + self.view.kwargs = {"username": self.account.username} + self.assertEqual(self.view.get_object(), self.account) + + def test_get_object_404(self): + self.view.kwargs = {"username": "not_registered_user"} + with self.assertRaises(Http404): + self.view.get_object() diff --git a/extended_accounts/views/tests/test_new_account.py b/extended_accounts/views/tests/test_new_account.py new file mode 100644 index 0000000..b15bccd --- /dev/null +++ b/extended_accounts/views/tests/test_new_account.py @@ -0,0 +1,103 @@ +from django.test import TestCase, RequestFactory, override_settings +from django.urls import reverse_lazy +from django.core import mail +from django.core.files.uploadedfile import SimpleUploadedFile +from django.template.response import TemplateResponse +from django.conf import settings +from extended_accounts.models import AccountModel as Account +from extended_accounts.views import NewAccountView +from PIL import Image +from io import BytesIO +import tempfile, shutil, os + +MEDIA_ROOT = tempfile.mkdtemp() + + +def create_test_image(): + image_buffer = BytesIO() + image_object = Image.new("RGB", (1, 1)) + image_object.save(image_buffer, "png") + image_buffer.seek(0) + image = SimpleUploadedFile( + "test_image.png", + image_buffer.read(), + ) + return image + + +@override_settings(MEDIA_ROOT=MEDIA_ROOT) +class NewAccountViewTestCase(TestCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.factory = RequestFactory() + cls.create_url = reverse_lazy("extended_accounts:new_account") + cls.login_url = reverse_lazy("extended_accounts:login") + + @classmethod + def tearDownClass(cls, *args, **kwargs): + shutil.rmtree( + MEDIA_ROOT, ignore_errors=True + ) ## At the end of the tests, the temporary directory is removed. ignore_errors = True ensures that this won't cause any issues, we don't care if this directory has errors when being removed + super().tearDownClass(*args, **kwargs) + + def test_render_get(self): + request = self.factory.get(self.create_url) + response = NewAccountView.as_view()(request) + self.assertEqual(200, response.status_code) + self.assertIsInstance(response, TemplateResponse) + self.assertIn("extended_accounts/new_account.html", response.template_name) + + def test_get_success_url(self): + view = NewAccountView() + self.assertEqual(view.get_success_url(), self.login_url) + + def test_create_user(self): + data = { + "username": "johndoe", + "first_name": "John", + "last_name": "Doe", + "email": "johndoe@mail.com", + "phone_number": 123456789, + "password1": "testpassword", + "password2": "testpassword", + "profile_image": create_test_image(), + } + request = self.factory.post(self.create_url, data) + response = NewAccountView.as_view()(request) + + self.assertEqual(302, response.status_code) ## Correct creation -> Redirect + self.assertEqual(response.url, self.login_url) + + ## Test that the user has been saved correctly. + account = Account.objects.get(username=data["username"]) + self.assertEqual(account.username, data["username"]) + self.assertEqual(account.email, data["email"]) + self.assertEqual(account.profile.first_name, data["first_name"]) + self.assertEqual(account.profile.last_name, data["last_name"]) + self.assertFalse( + account.is_active + ) ## Upon creation, the user isn't active until they confirm their account + self.assertEqual( + account.profile.phone_number, data["phone_number"] + ) ## The phone has been registered correctly in the profile + ## The image's been uploaded + self.assertIn( + account.profile.profile_image.name + ".png", os.listdir(MEDIA_ROOT) + ) + self.assertIn( + account.profile.profile_image.name + ".webp", os.listdir(MEDIA_ROOT) + ) + ## To check the password, the account must be active + account.update(is_active=True) + self.assertTrue(account.check_password("testpassword")) + + ## Check that the email was sent correctly + self.assertEqual(len(mail.outbox), 1) + sent_mail = mail.outbox[0] + self.assertEqual(sent_mail.subject, "Account Confirmation") + self.assertIn( + "johndoe", sent_mail.body + ) ## Verify that the email body contains the user's name (the content in the URL). We cannot verify the complete message because the token generated in the function may not be necessarily the same as we could generate here. In any case, this test is sufficient to see that the mail was sent correctly + self.assertEqual(sent_mail.from_email, settings.DEFAULT_FROM_EMAIL) + self.assertAlmostEqual(sent_mail.to, ["johndoe@mail.com"]) diff --git a/extended_accounts/views/tests/test_redirect_account.py b/extended_accounts/views/tests/test_redirect_account.py new file mode 100644 index 0000000..b5844b0 --- /dev/null +++ b/extended_accounts/views/tests/test_redirect_account.py @@ -0,0 +1,37 @@ +from django.test import TestCase, RequestFactory +from django.urls import reverse_lazy +from django.contrib.auth.models import AnonymousUser +from extended_accounts.models import AccountModel as Account +from extended_accounts.views import RedirectAccountView + + +class RedirectAccountTestCase(TestCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.account = Account.objects.create_user( + username="johndoe", email="johndoe@mail.com", phone_number=123456789 + ) + cls.redirect_account_url = reverse_lazy("extended_accounts:redirect_account") + cls.factory = RequestFactory() + + def test_redirect_if_user_authenticated(self): + request = self.factory.get(self.redirect_account_url) + request.user = self.account + response = RedirectAccountView.as_view()(request) + self.assertEqual( + response.url, + reverse_lazy( + "extended_accounts:detail_account", + kwargs={"username": self.account.username}, + ), + ) + + def test_redirect_if_user_not_authenticated(self): + request = self.factory.get(self.redirect_account_url) + request.user = AnonymousUser() + response = RedirectAccountView.as_view()(request) + self.assertEqual( + response.url, + f'/extended_accounts/login/?next={reverse_lazy("extended_accounts:redirect_account")}', + ) diff --git a/extended_accounts/views/tests/test_update_account.py b/extended_accounts/views/tests/test_update_account.py new file mode 100644 index 0000000..c035c48 --- /dev/null +++ b/extended_accounts/views/tests/test_update_account.py @@ -0,0 +1,155 @@ +from django.test import TestCase, RequestFactory, override_settings +from django.urls import reverse_lazy +from django.http import Http404 +from django.core.files.uploadedfile import SimpleUploadedFile +from extended_accounts.models import AccountModel as Account +from extended_accounts.views import UpdateAccountView +from PIL import Image +from io import BytesIO +import tempfile, shutil, os + +MEDIA_ROOT = tempfile.mkdtemp() + + +def create_test_image(): + image_buffer = BytesIO() + image_object = Image.new("RGB", (1, 1)) + image_object.save(image_buffer, "png") + image_buffer.seek(0) + image = SimpleUploadedFile( + "test_image.png", + image_buffer.read(), + ) + return image + + +@override_settings(MEDIA_ROOT=MEDIA_ROOT) +class UpdateAccountViewTestCase(TestCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + cls.account = Account.objects.create_user( + username="johndoe", + email="johndoe@mail.com", + first_name="John", + last_name="Doe", + phone_number=123456789, + profile_image=create_test_image(), + ) + cls.view = UpdateAccountView() + cls.update_url = reverse_lazy( + "extended_accounts:update_account", + kwargs={"username": cls.account.username}, + ) + cls.factory = RequestFactory() + + @classmethod + def tearDownClass(cls, *args, **kwargs): + shutil.rmtree( + MEDIA_ROOT, ignore_errors=True + ) ## At the end of the tests, the temporary directory is removed. ignore_errors = True ensures that this won't cause any issues, we don't care if this directory has errors when being removed + super().tearDownClass(*args, **kwargs) + + def test_get_success_url(self): + self.assertEqual( + self.view.get_success_url(), + reverse_lazy("extended_accounts:redirect_account"), + ) + + def test_get_object_correct(self): + self.view.kwargs = {"username": self.account.username} + self.assertEqual(self.view.get_object(), self.account) + + def test_get_object_404(self): + self.view.kwargs = {"username": "not_registered_user"} + with self.assertRaises(Http404): + self.view.get_object() + + def test_get_form_kwargs(self): + request = self.factory.get(self.update_url) + self.view.setup(request) + self.view.object = self.account + kwargs = self.view.get_form_kwargs() + self.assertEqual(kwargs["instance"], self.account) + self.assertEqual( + kwargs["initial"]["first_name"], self.account.profile.first_name + ) + self.assertEqual(kwargs["initial"]["last_name"], self.account.profile.last_name) + self.assertEqual( + kwargs["initial"]["phone_number"], self.account.profile.phone_number + ) + self.assertEqual( + kwargs["initial"]["profile_image"], self.account.profile.profile_image + ) + + def test_test_func_OK(self): + request = self.factory.get(self.update_url) + request.user = self.account + self.view.setup(request, username=self.account.username) + self.assertTrue(self.view.test_func()) + + def test_test_func_404(self): + request = self.factory.get(self.update_url) + other_account = Account.objects.create_user( + username="other", email="other@mail.com", phone_number=987654321 + ) + request.user = other_account + self.view.setup(request, username=self.account.username) + with self.assertRaises(Http404): + self.view.test_func() + + def test_update_user(self): + request = self.factory.get(self.update_url) + request.user = self.account + response = UpdateAccountView.as_view()(request, username=self.account.username) + + initial_data = response.context_data["form"].initial + self.assertEqual(initial_data["username"], self.account.username) + self.assertEqual(initial_data["first_name"], self.account.profile.first_name) + self.assertEqual(initial_data["last_name"], self.account.profile.last_name) + self.assertEqual(initial_data["email"], self.account.email) + self.assertEqual( + initial_data["phone_number"], self.account.profile.phone_number + ) + self.assertIn( + initial_data["profile_image"].name + ".png", os.listdir(MEDIA_ROOT) + ) + self.assertIn( + initial_data["profile_image"].name + ".webp", os.listdir(MEDIA_ROOT) + ) + + new_data = { + "username": self.account.username, + "first_name": "Johnny", + "last_name": "Doey", + "email": "johhnydoey@mail.com", + "phone_number": 987654321, + "profile_image": create_test_image(), + } + + request = self.factory.post(self.update_url, new_data) + request.user = self.account + response = UpdateAccountView.as_view()(request, username=self.account.username) + self.assertEqual(302, response.status_code) ## Correct update -> Redirection + self.assertEqual( + response.url, reverse_lazy("extended_accounts:redirect_account") + ) + self.account.refresh_from_db() + self.assertEqual(self.account.profile.first_name, new_data["first_name"]) + self.assertEqual(self.account.profile.last_name, new_data["last_name"]) + self.assertEqual(self.account.email, new_data["email"]) + self.assertEqual(self.account.profile.phone_number, new_data["phone_number"]) + ## The image has been properly uploaded to the server with its different extensions, similarly, the old image is no longer present + self.assertIn(MEDIA_ROOT, self.account.profile.profile_image.path) + self.assertIn( + self.account.profile.profile_image.name + ".png", os.listdir(MEDIA_ROOT) + ) + self.assertIn( + self.account.profile.profile_image.name + ".webp", os.listdir(MEDIA_ROOT) + ) + self.assertNotIn( + initial_data["profile_image"].name + ".png", os.listdir(MEDIA_ROOT) + ) + self.assertNotIn( + initial_data["profile_image"].name + ".webp", os.listdir(MEDIA_ROOT) + ) diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..d3234fa --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_extended_accounts.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e492d9e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "django_extended_accounts" +version = "1.0.0" +authors = [ + { name = "Tomas Senovilla Polo", email = "tspscgs@gmail.com" } +] +description="A Django app that may serve as a template for extending the Django's default User model into a more realistic accounts system" +readme = "README.md" +classifiers = ['Programming Language :: Python :: 3', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Framework :: Django'] +requires-python = "^3.11" + +[tool.coverage.run] +relative_files = true +omit = [ + "manage.py", + "__init__.py", + "*/migrations/*", + "django_extended_accounts/*", + "urls.py" +] +data_file = "coverage/.coverage" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..42e8a2b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,33 @@ +amqp==5.2.0 +asgiref==3.7.2 +billiard==4.2.0 +black==24.2.0 +celery==5.3.6 +cfgv==3.4.0 +click==8.1.7 +click-didyoumean==0.3.0 +click-plugins==1.1.1 +click-repl==0.3.0 +coverage==7.4.3 +coverage-badge==1.1.0 +distlib==0.3.8 +Django==5.0.2 +filelock==3.13.1 +identify==2.5.35 +kombu==5.3.5 +mypy-extensions==1.0.0 +nodeenv==1.8.0 +packaging==23.2 +pathspec==0.12.1 +pillow==10.2.0 +platformdirs==4.2.0 +pre-commit==3.6.2 +prompt-toolkit==3.0.43 +python-dateutil==2.8.2 +PyYAML==6.0.1 +six==1.16.0 +sqlparse==0.4.4 +tzdata==2024.1 +vine==5.1.0 +virtualenv==20.25.1 +wcwidth==0.2.13