diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e8e0581 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +**/__pycache__ +**/.venv +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +/postgres-data/ \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 383c310..2004728 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -38,11 +38,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -53,7 +53,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -67,4 +67,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 9ac9dd6..e9458f9 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -16,18 +16,15 @@ jobs: steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 with: python-version: 3.10.4 - name: Install required Ubuntu packages run: | - sudo apt-get install gdal-bin - - # - name: Create needed postgis extensions - # run: | - # psql -h localhost -U postgres template1 -c 'create extension postgis;' + sudo apt-get update && sudo apt-get install gdal-bin + - name: Install PyPI dependencies run: | python -m pip install --upgrade pip @@ -35,7 +32,7 @@ jobs: - name: Run Python side code neatness tests run: | flake8 - black --check . + # black --check . isort . -c - name: Run pytest code functionality tests run: | @@ -45,7 +42,7 @@ jobs: pip install coverage coverage report -m - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v3 # Majority of the tests require database services: # Label used to access the service container diff --git a/.gitignore b/.gitignore index b6e4761..4bfd533 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,9 @@ dmypy.json # Pyre type checker .pyre/ + +# static files +static/ + +# Django project +.django_secret \ No newline at end of file diff --git a/README.md b/README.md index 2be5dc4..34ed7af 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,54 @@ -# mpbackend -Mobility Profile Backend +# Mobility Profile Backend +This is the backend for the Mobility Profile + +## Installation with Docker Compose +First configure development environment settings as stated in `config_dev.env.example`. + +### Running the application +Run application with `docker-compose up` +This will startup and bind local postgres and mobilityprofile backend. + +### Runnig the application in production +docker-compose -f docker-compose.yml -f docker-compose.prod.yml up + +### Importing questions +To import questions run: `docker-compose run mpbackend import_questions` + + +## Installation without Docker +1. +First, install the necessary Debian packages. +TODO, add packages. + +2. Clone the repository. +``` +git clone https://github.com/City-of-Turku/mpbackend.git +``` +3. Install python 3.10 and pip requiremends +Be sure to load the **environment** before installing the requirements. +``` +pip install pip-tools +pip install -r requirements.txt +``` +4. Setup the PostGIS database. + +Please note, we recommend PostgreSQL version 13 or higher. +Local setup: + +``` +sudo su postgres +psql template1 -c 'CREATE EXTENSION IF NOT EXISTS postgis;' +createuser -RSPd mobilityprofile +createdb -O mobilityprofile -T template1 -l fi_FI.UTF-8 -E utf8 mobilityprofile +``` + +5. Create database tables. +``` +./manage.py migrate +``` + +6. Import questions +``` +./manage.py import_questions +``` + diff --git a/account/__init__.py b/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/account/admin.py b/account/admin.py new file mode 100644 index 0000000..9ef3a32 --- /dev/null +++ b/account/admin.py @@ -0,0 +1,54 @@ +from django import forms +from django.contrib import admin + +from .models import MailingList, Profile, User + +admin.site.register(User) + + +class ProfileAdmin(admin.ModelAdmin): + list_display = ("user", "result") + + def result(self, obj): + if obj.user.result: + return obj.user.result.value + else: + return None + + +class MailingListAdminForm(forms.ModelForm): + csv_emails = forms.CharField(widget=forms.Textarea(attrs={"rows": 20, "cols": 120})) + + class Meta: + model = MailingList + fields = ["result"] + + +class MailingListAdmin(admin.ModelAdmin): + list_display = ("result", "csv_emails") + + readonly_fields = ("result",) + form = MailingListAdminForm + + def csv_emails(self, obj): + return obj.csv_emails + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + form.base_fields["csv_emails"].initial = self.csv_emails(obj) + return form + + def change_view(self, request, object_id, form_url="", extra_context=None): + extra_context = extra_context or {} + extra_context["show_save_and_add_another"] = False + extra_context["show_save_and_continue"] = False + return super().change_view( + request, object_id, form_url, extra_context=extra_context + ) + + def has_delete_permission(self, request, obj=None): + return False + + +admin.site.register(Profile, ProfileAdmin) +admin.site.register(MailingList, MailingListAdmin) diff --git a/account/api/__init__.py b/account/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/account/api/serializers.py b/account/api/serializers.py new file mode 100644 index 0000000..c050532 --- /dev/null +++ b/account/api/serializers.py @@ -0,0 +1,55 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from account.models import MailingListEmail, Profile + + +class SubscribeSerializer(serializers.Serializer): + email = serializers.CharField() + result = serializers.IntegerField() + + +class UnSubscribeSerializer(serializers.Serializer): + email = serializers.CharField() + + +class PublicUserSerializer(serializers.ModelSerializer): + class Meta: + model = get_user_model() + fields = ["id"] + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = get_user_model() + fields = [ + "id", + "username", + "email", + "first_name", + "last_name", + "last_login", + "date_joined", + "is_staff", + "is_active", + "email_verified", + ] + + +class ProfileSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = Profile + fields = [ + "id", + "year_of_birth", + "postal_code", + "optional_postal_code", + "is_filled_for_fun", + "result_can_be_used", + ] + + +class MailingListEmailSerializer(serializers.ModelSerializer): + class Meta: + model = MailingListEmail + fields = "__all__" diff --git a/account/api/urls.py b/account/api/urls.py new file mode 100644 index 0000000..10cf006 --- /dev/null +++ b/account/api/urls.py @@ -0,0 +1,13 @@ +from django.urls import include, path +from rest_framework import routers + +from . import views + +app_name = "account" + +# Create a router and register our viewsets with it. +router = routers.DefaultRouter() +router.register("profile", views.ProfileViewSet, "profiles") + +# The API URLs are now determined automatically by the router. +urlpatterns = [path("", include(router.urls), name="account")] diff --git a/account/api/views.py b/account/api/views.py new file mode 100644 index 0000000..37a580f --- /dev/null +++ b/account/api/views.py @@ -0,0 +1,122 @@ +from django import db +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from drf_spectacular.utils import extend_schema, OpenApiResponse +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.mixins import UpdateModelMixin +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.throttling import AnonRateThrottle + +from account.models import MailingList, MailingListEmail, Profile +from profiles.models import Result + +from .serializers import ProfileSerializer, SubscribeSerializer, UnSubscribeSerializer + +all_views = [] + + +class UnsubscribeRateThrottle(AnonRateThrottle): + """ + The AnonRateThrottle will only ever throttle unauthenticated users. + The IP address of the incoming request is used to generate a unique key to throttle against. + """ + + rate = "10/day" + + +class ProfileViewSet(UpdateModelMixin, viewsets.GenericViewSet): + queryset = Profile.objects.all().select_related("user").order_by("id") + serializer_class = ProfileSerializer + + def get_permissions(self): + if self.action in ["unsubscribe"]: + permission_classes = [AllowAny] + else: + permission_classes = [IsAuthenticated] + + return [permission() for permission in permission_classes] + + def update(self, request, *args, **kwargs): + user = request.user + instance = user.profile + serializer = self.serializer_class( + instance=instance, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + return Response(data=serializer.data, status=status.HTTP_200_OK) + else: + return Response(data=serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @extend_schema( + description="Subscribe the email to the mailing list attached to the result.", + request=SubscribeSerializer, + responses={ + 201: OpenApiResponse(description="subscribed"), + 400: OpenApiResponse( + description="Validation error, detailed information in response" + ), + }, + ) + @action( + detail=False, + methods=["POST"], + permission_classes=[IsAuthenticated], + ) + @db.transaction.atomic + def subscribe(self, request): + result = Result.objects.filter(id=request.data.get("result", None)).first() + if not result: + return Response("'result' not found", status=status.HTTP_400_BAD_REQUEST) + + email = request.data.get("email", None) + if not email: + return Response("No 'email' provided", status=status.HTTP_400_BAD_REQUEST) + if MailingListEmail.objects.filter(email=email).count() > 0: + return Response("'email' exists", status=status.HTTP_400_BAD_REQUEST) + try: + validate_email(email) + except ValidationError as e: + return Response(f"Invalid email: {e}", status=status.HTTP_400_BAD_REQUEST) + mailing_list = MailingList.objects.filter(result=result).first() + if not mailing_list: + # In case mailing list is not created for the result, it is created. + mailing_list = MailingList.objects.create(result=result) + + MailingListEmail.objects.create(mailing_list=mailing_list, email=email) + return Response("subscribed", status=status.HTTP_201_CREATED) + + @extend_schema( + description="Unaubscribe the email from the mailing list attached to the result." + f"Note, there is a rate-limit of {UnsubscribeRateThrottle.rate} requests.", + request=UnSubscribeSerializer, + responses={ + 200: OpenApiResponse(description="unsubscribed"), + 400: OpenApiResponse( + description="Validation error, detailed information in response" + ), + }, + ) + @action( + detail=False, + methods=["POST"], + permission_classes=[AllowAny], + throttle_classes=[UnsubscribeRateThrottle], + ) + def unsubscribe(self, request): + email = request.data.get("email", None) + if not email: + return Response("No 'email' provided", status=status.HTTP_400_BAD_REQUEST) + qs = MailingListEmail.objects.filter(email=email) + if not qs.exists(): + return Response( + f"{email} does not exists", status=status.HTTP_400_BAD_REQUEST + ) + + qs.delete() + return Response(f"unsubscribed {email}", status=status.HTTP_200_OK) + + +all_views.append({"class": ProfileViewSet, "name": "profile"}) diff --git a/account/apps.py b/account/apps.py new file mode 100644 index 0000000..2c684a9 --- /dev/null +++ b/account/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "account" diff --git a/account/migrations/0001_initial.py b/account/migrations/0001_initial.py new file mode 100644 index 0000000..6cc1a23 --- /dev/null +++ b/account/migrations/0001_initial.py @@ -0,0 +1,149 @@ +# Generated by Django 4.1.10 on 2023-09-20 09:29 + +import uuid + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, null=True, unique=True + ), + ), + ("email_verified", models.BooleanField(default=False)), + ("is_active", models.BooleanField(default=True)), + ("is_generated", models.BooleanField(default=False)), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ], + options={ + "db_table": "auth_user", + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name="Profile", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "gender", + models.CharField( + blank=True, + choices=[("M", "Male"), ("F", "Female"), ("NB", "Nonbinary")], + max_length=2, + ), + ), + ("postal_code", models.CharField(max_length=10, null=True)), + ("optional_postal_code", models.CharField(max_length=10, null=True)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="profile", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/account/migrations/0002_initial.py b/account/migrations/0002_initial.py new file mode 100644 index 0000000..05d49f4 --- /dev/null +++ b/account/migrations/0002_initial.py @@ -0,0 +1,40 @@ +# Generated by Django 4.1.10 on 2023-09-20 09:29 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("account", "0001_initial"), + ("auth", "0012_alter_user_first_name_max_length"), + ("profiles", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="result", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="users", + to="profiles.result", + ), + ), + migrations.AddField( + model_name="user", + name="user_permissions", + field=models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ] diff --git a/account/migrations/0003_user_add_postal_code_result_saved.py b/account/migrations/0003_user_add_postal_code_result_saved.py new file mode 100644 index 0000000..b6a8106 --- /dev/null +++ b/account/migrations/0003_user_add_postal_code_result_saved.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.10 on 2023-09-26 07:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("account", "0002_initial"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="postal_code_result_saved", + field=models.BooleanField(default=False), + ), + ] diff --git a/account/migrations/0004_remove_profile_gender.py b/account/migrations/0004_remove_profile_gender.py new file mode 100644 index 0000000..3d8f91c --- /dev/null +++ b/account/migrations/0004_remove_profile_gender.py @@ -0,0 +1,16 @@ +# Generated by Django 4.1.10 on 2024-01-08 08:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("account", "0003_user_add_postal_code_result_saved"), + ] + + operations = [ + migrations.RemoveField( + model_name="profile", + name="gender", + ), + ] diff --git a/account/migrations/0005_profile_is_filled_for_fun.py b/account/migrations/0005_profile_is_filled_for_fun.py new file mode 100644 index 0000000..f0c6c91 --- /dev/null +++ b/account/migrations/0005_profile_is_filled_for_fun.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.10 on 2024-01-08 08:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("account", "0004_remove_profile_gender"), + ] + + operations = [ + migrations.AddField( + model_name="profile", + name="is_filled_for_fun", + field=models.BooleanField(default=False), + ), + ] diff --git a/account/migrations/0006_profile_result_can_be_used.py b/account/migrations/0006_profile_result_can_be_used.py new file mode 100644 index 0000000..0b165d4 --- /dev/null +++ b/account/migrations/0006_profile_result_can_be_used.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.10 on 2024-01-08 08:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("account", "0005_profile_is_filled_for_fun"), + ] + + operations = [ + migrations.AddField( + model_name="profile", + name="result_can_be_used", + field=models.BooleanField(default=True), + ), + ] diff --git a/account/migrations/0007_profile_age.py b/account/migrations/0007_profile_age.py new file mode 100644 index 0000000..387fb47 --- /dev/null +++ b/account/migrations/0007_profile_age.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.10 on 2024-01-08 10:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("account", "0006_profile_result_can_be_used"), + ] + + operations = [ + migrations.AddField( + model_name="profile", + name="age", + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/account/migrations/0008_mailinglist.py b/account/migrations/0008_mailinglist.py new file mode 100644 index 0000000..0cefd23 --- /dev/null +++ b/account/migrations/0008_mailinglist.py @@ -0,0 +1,38 @@ +# Generated by Django 4.1.10 on 2024-01-12 08:36 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("profiles", "0009_subquestioncondition"), + ("account", "0007_profile_age"), + ] + + operations = [ + migrations.CreateModel( + name="MailingList", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "result", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="mailing_list", + to="profiles.result", + ), + ), + ], + ), + ] diff --git a/account/migrations/0009_mailinglistemail.py b/account/migrations/0009_mailinglistemail.py new file mode 100644 index 0000000..a3d17fd --- /dev/null +++ b/account/migrations/0009_mailinglistemail.py @@ -0,0 +1,36 @@ +# Generated by Django 4.1.10 on 2024-01-12 09:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("account", "0008_mailinglist"), + ] + + operations = [ + migrations.CreateModel( + name="MailingListEmail", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("email", models.EmailField(max_length=254, unique=True)), + ( + "mailing_list", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="emails", + to="account.mailinglist", + ), + ), + ], + ), + ] diff --git a/account/migrations/0010_rename_age_profile_year_of_birth.py b/account/migrations/0010_rename_age_profile_year_of_birth.py new file mode 100644 index 0000000..c514986 --- /dev/null +++ b/account/migrations/0010_rename_age_profile_year_of_birth.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.10 on 2024-01-17 12:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("account", "0009_mailinglistemail"), + ] + + operations = [ + migrations.RenameField( + model_name="profile", + old_name="age", + new_name="year_of_birth", + ), + ] diff --git a/account/migrations/__init__.py b/account/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/account/models.py b/account/models.py new file mode 100644 index 0000000..d50254c --- /dev/null +++ b/account/models.py @@ -0,0 +1,74 @@ +import uuid + +from django.conf import settings +from django.contrib.auth.models import AbstractUser +from django.db import models + +from profiles.models import Result + + +class User(AbstractUser): + id = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False) + result = models.ForeignKey( + "profiles.Result", + related_name="users", + null=True, + blank=True, + on_delete=models.CASCADE, + ) + email = models.EmailField(unique=True, blank=True, null=True) + email_verified = models.BooleanField(default=False) + is_active = models.BooleanField(default=True) + is_generated = models.BooleanField(default=False) + # Flag that is used to ensure the user is only Once calculated to the PostalCodeResults model. + postal_code_result_saved = models.BooleanField(default=False) + + def save(self, *args, **kwargs): + """Makes email lowercase always""" + if self.email: + self.email = self.email.lower() if len(self.email) > 0 else self.email + super(User, self).save(*args, **kwargs) + + class Meta: + db_table = "auth_user" + + +class Profile(models.Model): + user = models.OneToOneField( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile" + ) + year_of_birth = models.PositiveSmallIntegerField(null=True, blank=True) + postal_code = models.CharField(max_length=10, null=True) + optional_postal_code = models.CharField(max_length=10, null=True) + is_filled_for_fun = models.BooleanField(default=False) + result_can_be_used = models.BooleanField(default=True) + + def __str__(self): + return self.user.username + + +class MailingList(models.Model): + result = models.ForeignKey( + Result, + related_name="mailing_list", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + + def __str__(self): + return getattr(self.result, "topic", None) + + @property + def csv_emails(self): + return ",".join([e.email for e in self.emails.all()]) + + +class MailingListEmail(models.Model): + email = models.EmailField(unique=True) + mailing_list = models.ForeignKey( + MailingList, related_name="emails", on_delete=models.CASCADE + ) + + def __str__(self): + return self.email diff --git a/account/tests/__init__.py b/account/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/account/tests/conftest.py b/account/tests/conftest.py new file mode 100644 index 0000000..958fc90 --- /dev/null +++ b/account/tests/conftest.py @@ -0,0 +1,61 @@ +import pytest +from rest_framework.authtoken.models import Token +from rest_framework.test import APIClient + +from account.models import MailingList, MailingListEmail, Profile, User +from profiles.models import Result + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def api_client_authenticated(users): + user = users.get(username="test1") + token = Token.objects.create(user=user) + api_client = APIClient() + api_client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + return api_client + + +@pytest.fixture() +def api_client_with_custom_ip_address(ip_address): + return APIClient(REMOTE_ADDR=ip_address) + + +@pytest.fixture +def users(): + User.objects.create(username="test1") + return User.objects.all() + + +@pytest.fixture +def profiles(users): + test_user = User.objects.get(username="test1") + Profile.objects.create(user=test_user) + return Profile.objects.all() + + +@pytest.fixture +def results(): + Result.objects.create(topic="Car traveller") + Result.objects.create(topic="Habit traveller") + return Result.objects.all() + + +@pytest.fixture +def mailing_lists(results): + MailingList.objects.create(result=results.first()) + return MailingList.objects.all() + + +@pytest.fixture +def mailing_list_emails(mailing_lists): + for c in range(20): + MailingListEmail.objects.create( + email=f"test_{c}@test.com", mailing_list=mailing_lists.first() + ) + + return MailingListEmail.objects.all() diff --git a/account/tests/test_api.py b/account/tests/test_api.py new file mode 100644 index 0000000..03c8a72 --- /dev/null +++ b/account/tests/test_api.py @@ -0,0 +1,237 @@ +import time +from datetime import timedelta + +import pytest +from django.conf import settings +from django.utils import timezone +from freezegun import freeze_time +from rest_framework.reverse import reverse + +from account.models import MailingList, MailingListEmail, User + +from .utils import check_method_status_codes, patch + +ALL_METHODS = ("get", "post", "put", "patch", "delete") + + +@pytest.mark.django_db +def test_token_expiration(api_client_authenticated, users, profiles): + user = users.get(username="test1") + url = reverse("account:profiles-detail", args=[user.id]) + with freeze_time( + timezone.now() + timedelta(hours=settings.TOKEN_EXPIRED_AFTER_HOURS - 1) + ): + patch(api_client_authenticated, url, {"year_of_birth": 42}) + + with freeze_time( + timezone.now() + timedelta(hours=settings.TOKEN_EXPIRED_AFTER_HOURS + 1) + ): + patch(api_client_authenticated, url, {"year_of_birth": 42}, 401) + + +@pytest.mark.django_db +def test_unauthenticated_cannot_do_anything(api_client, users): + # TODO, add start-poll url after recaptcha integration + urls = [ + reverse("account:profiles-detail", args=[users.get(username="test1").id]), + ] + check_method_status_codes(api_client, urls, ALL_METHODS, 401) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "ip_address", + [ + ("192.168.1.40"), + ], +) +def test_mailing_list_unsubscribe_throttling( + api_client_with_custom_ip_address, mailing_list_emails +): + num_requests = 10 + url = reverse("account:profiles-unsubscribe") + count = 0 + while count < num_requests: + response = api_client_with_custom_ip_address.post( + url, {"email": f"test_{count}@test.com"} + ) + assert response.status_code == 200 + count += 1 + time.sleep(2) + response = api_client_with_custom_ip_address.post( + url, {"email": f"test_{count}@test.com"} + ) + assert response.status_code == 429 + + +@pytest.mark.django_db +def test_profile_created(api_client): + url = reverse("profiles:question-start-poll") + response = api_client.post(url) + assert response.status_code == 200 + assert User.objects.all().count() == 1 + user = User.objects.first() + assert user.is_generated is True + assert user.profile.postal_code is None + + +@pytest.mark.django_db +def test_profile_patch_postal_code(api_client_authenticated, users, profiles): + user = users.get(username="test1") + url = reverse("account:profiles-detail", args=[user.id]) + patch(api_client_authenticated, url, {"postal_code": "20210"}) + user.refresh_from_db() + assert user.profile.postal_code == "20210" + + +@pytest.mark.django_db +def test_profile_patch_postal_code_unauthenticated(api_client, users, profiles): + user = users.get(username="test1") + url = reverse("account:profiles-detail", args=[user.id]) + # Test update after logout (end-poll) + patch(api_client, url, {"postal_code": "20210"}, status_code=401) + + +@pytest.mark.django_db +def test_profile_patch_optional_postal_code(api_client_authenticated, users, profiles): + user = users.get(username="test1") + url = reverse("account:profiles-detail", args=[user.id]) + patch(api_client_authenticated, url, {"optional_postal_code": "20100"}) + user.refresh_from_db() + assert user.profile.optional_postal_code == "20100" + url = reverse("profiles:question-end-poll") + + +@pytest.mark.django_db +def test_profile_patch_year_of_birth(api_client_authenticated, users, profiles): + user = users.get(username="test1") + url = reverse("account:profiles-detail", args=[user.id]) + patch(api_client_authenticated, url, {"year_of_birth": 42}) + user.refresh_from_db() + assert user.profile.year_of_birth == 42 + + +@pytest.mark.django_db +def test_profile_patch_is_filled_for_fun(api_client_authenticated, users, profiles): + user = users.get(username="test1") + url = reverse("account:profiles-detail", args=[user.id]) + assert user.profile.is_filled_for_fun is False + patch(api_client_authenticated, url, {"is_filled_for_fun": True}) + user.refresh_from_db() + assert user.profile.is_filled_for_fun is True + + +@pytest.mark.django_db +def test_profile_patch_result_can_be_used(api_client_authenticated, users, profiles): + user = users.get(username="test1") + url = reverse("account:profiles-detail", args=[user.id]) + assert user.profile.result_can_be_used is True + patch(api_client_authenticated, url, {"result_can_be_used": False}) + user.refresh_from_db() + assert user.profile.result_can_be_used is False + + +@pytest.mark.django_db +def test_mailing_list_unauthenticated_subscribe(api_client, results): + url = reverse("account:profiles-subscribe") + response = api_client.post( + url, {"email": "test@test.com", "result": results.first().id} + ) + assert response.status_code == 401 + + +@pytest.mark.django_db +def test_mailing_list_subscribe( + api_client_authenticated, users, results, mailing_lists +): + url = reverse("account:profiles-subscribe") + response = api_client_authenticated.post( + url, {"email": "test@test.com", "result": results.first().id} + ) + assert response.status_code == 201 + assert MailingListEmail.objects.count() == 1 + assert MailingListEmail.objects.first().email == "test@test.com" + assert ( + MailingList.objects.first().emails.first() == MailingListEmail.objects.first() + ) + + +@pytest.mark.django_db +def test_mailing_list_is_created_on_subscribe(api_client_authenticated, users, results): + assert MailingList.objects.count() == 0 + url = reverse("account:profiles-subscribe") + response = api_client_authenticated.post( + url, {"email": "test@test.com", "result": results.first().id} + ) + assert response.status_code == 201 + assert MailingList.objects.count() == 1 + assert MailingListEmail.objects.count() == 1 + + +@pytest.mark.django_db +def test_mailing_list_subscribe_with_invalid_emails( + api_client_authenticated, users, results +): + assert MailingList.objects.count() == 0 + url = reverse("account:profiles-subscribe") + for email in [ + "john.doe@company&.com", + "invalid-email.com", + "john.doe@", + "john.doe@example", + "john.doe@example", + ]: + response = api_client_authenticated.post( + url, {"email": email, "result": results.first().id} + ) + assert response.status_code == 400 + assert MailingList.objects.count() == 0 + assert MailingList.objects.count() == 0 + + +@pytest.mark.django_db +def test_mailing_list_subscribe_with_invalid_post_data( + api_client_authenticated, users, results +): + url = reverse("account:profiles-subscribe") + # Missing email + response = api_client_authenticated.post(url, {"result": results.first().id}) + assert response.status_code == 400 + assert MailingList.objects.count() == 0 + assert MailingList.objects.count() == 0 + # Missing result + response = api_client_authenticated.post(url, {"email": "test@test.com"}) + assert response.status_code == 400 + assert MailingList.objects.count() == 0 + assert MailingList.objects.count() == 0 + + +@pytest.mark.django_db +def test_mailing_list_unsubscribe(api_client, mailing_list_emails): + num_mailing_list_emails = mailing_list_emails.count() + assert MailingListEmail.objects.count() == num_mailing_list_emails + assert MailingList.objects.first().emails.count() == num_mailing_list_emails + url = reverse("account:profiles-unsubscribe") + response = api_client.post(url, {"email": "test_0@test.com"}) + assert response.status_code == 200 + assert MailingListEmail.objects.count() == num_mailing_list_emails - 1 + assert MailingList.objects.first().emails.count() == num_mailing_list_emails - 1 + + +@pytest.mark.django_db +def test_mailing_list_unsubscribe_non_existing_email(api_client, mailing_list_emails): + num_mailing_list_emails = mailing_list_emails.count() + assert MailingListEmail.objects.count() == num_mailing_list_emails + assert MailingList.objects.first().emails.count() == num_mailing_list_emails + url = reverse("account:profiles-unsubscribe") + response = api_client.post(url, {"email": "idonotexist@test.com"}) + assert response.status_code == 400 + assert MailingListEmail.objects.count() == num_mailing_list_emails + assert MailingList.objects.first().emails.count() == num_mailing_list_emails + + +@pytest.mark.django_db +def test_mailing_list_unsubscribe_email_not_provided(api_client, mailing_list_emails): + url = reverse("account:profiles-unsubscribe") + response = api_client.post(url) + assert response.status_code == 400 diff --git a/account/tests/utils.py b/account/tests/utils.py new file mode 100644 index 0000000..2a89e69 --- /dev/null +++ b/account/tests/utils.py @@ -0,0 +1,36 @@ +def patch(api_client, url, data=None, status_code=200): + response = api_client.patch(url, data) + assert response.status_code == status_code, "%s %s" % ( + response.status_code, + response.data, + ) + return response.json() + + +def check_method_status_codes(api_client, urls, methods, status_code, **kwargs): + # accept also a single url as a string + if isinstance(urls, str): + urls = (urls,) + + for url in urls: + for method in methods: + response = getattr(api_client, method)(url) + assert ( + response.status_code == status_code + ), "%s %s expected %s, got %s %s" % ( + method, + url, + status_code, + response.status_code, + response.data, + ) + error_code = kwargs.get("error_code") + if error_code: + assert ( + response.data["code"] == error_code + ), "%s %s expected error_code %s, got %s" % ( + method, + url, + error_code, + response.data["code"], + ) diff --git a/config_dev.env.example b/config_dev.env.example new file mode 100644 index 0000000..d3b13d3 --- /dev/null +++ b/config_dev.env.example @@ -0,0 +1,64 @@ +# Whether to run Django in debug mode +# Django setting: DEBUG https://docs.djangoproject.com/en/4.2/ref/settings/#debug +DEBUG=True + +# Configures database for mpbackend using URL style. The format is +# +# postgis://USER:PASSWORD@HOST:PORT/NAME +# +# Unused components may be left out, only Postgis is supported. +# The example below configures mpbackend to use local PostgreSQL database +# called "mpbackend", connecting same as username as Django is running as. +# Django setting: DATABASES (but not directly) https://docs.djangoproject.com/en/4.2/ref/settings/#databases +# When running with docker change 'localhost' host 'postgres'. +DATABASE_URL=postgis://mobilityprofile:mobilityprofile@localhost:5432/mobilityprofile + +# List of Host-values, that mpbackend will accept in requests. +# This setting is a Django protection measure against HTTP Host-header attacks +# https://docs.djangoproject.com/en/2.2/topics/security/#host-headers-virtual-hosting +# Specified as a comma separated list of allowed values. Note that this does +# NOT matter if you are running with DEBUG +# Django setting: ALLOWED_HOSTS https://docs.djangoproject.com/en/4.2/ref/settings/#allowed-hosts +ALLOWED_HOSTS=127.0.0.1,localhost + +# List of Host-values, that are allowed by the django-cors-headers. +CORS_ORIGIN_WHITELIST=http://localhost:8080 +# Django setting: LANGUAGES +# https://docs.djangoproject.com/en/2.2/ref/settings/#languages +LANGUAGES=fi,sv,en + +# Email settings +EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend +EMAIL_HOST=smtp.turku.fi +EMAIL_HOST_USER=liikkumisprofiili@turku.fi +EMAIL_PORT=25 +EMAIL_USE_TLS=True + +# Media root is the place in file system where Django and, by extension +# smbackend stores "uploaded" files. This means any and all files +# that are inputted through importers or API +# Django setting: MEDIA_ROOT https://docs.djangoproject.com/en/4.2/ref/settings/#media-root +#MEDIA_ROOT=/home/mpbackend/media + +# Static root is the place where smbackend will install any static +# files that need to be served to clients. For smbackend this is mostly +# JS and CSS for the API exploration interface + admin +# Django setting: STATIC_ROOT https://docs.djangoproject.com/en/4.2/ref/settings/#static-root +#STATIC_ROOT=/home/mpbackend/static + +# Media URL is address (URL) where users can access files in MEDIA_ROOT +# through http. Ie. where your uploaded files are publicly accessible. +# In the simple case this is a relative URL to same server as API +# Django setting: MEDIA_URL https://docs.djangoproject.com/en/4.2/ref/settings/#media-url +MEDIA_URL=/media/ + +# Static URL is address (URL) where users can access files in STATIC_ROOT +# through http. Same factors apply as to MEDIA_URL +# Django setting: STATIC_URL https://docs.djangoproject.com/en/4.2/ref/settings/#static-url +STATIC_URL=/static/ + +# If set docker will create a superuser, the user can be used to +# access the admin page from where importers can be run. +# DJANGO_SUPERUSER_PASSWORD=***** +# DJANGO_SUPERUSER_EMAIL=admin@admin.com +# DJANGO_SUPERUSER_USERNAME=admin diff --git a/deploy/docker_nginx.conf b/deploy/docker_nginx.conf new file mode 100644 index 0000000..53d0c90 --- /dev/null +++ b/deploy/docker_nginx.conf @@ -0,0 +1,37 @@ +events { + worker_connections 1024; +} +http { + upstream django { + server unix:///mpbackend/mpbackend.sock; + #server 127.0.01:8000; + } + + server { + root /mpbackend; + listen 80; + # TODO, add server name, subdomain liikkumisprofiili.turku.fi etc. + server_name localhost + charset utf-8; + # max upload size + client_max_body_size 75M; + include /etc/nginx/mime.types; + # default_type application/octet-stream; + # Django media and static files + location /media { + alias /mpbackend/media; + } + location /static { + alias /mpbackend/static; + } + # Send all non-media requests to the Django server. + # location ~ "^/static/[0-9a-fA-F]{8}\/(.*)$" { + # rewrite "^/static/[0-9a-fA-F]{8}\/(.*)" /$1 last; + # } + location / { + # mapped to umupstream django socket + uwsgi_pass django; + include /etc/nginx/uwsgi_params; + } + } +} \ No newline at end of file diff --git a/deploy/docker_uwsgi.ini b/deploy/docker_uwsgi.ini new file mode 100644 index 0000000..558ab0a --- /dev/null +++ b/deploy/docker_uwsgi.ini @@ -0,0 +1,25 @@ +[uwsgi] +# full path to Django project's root directory +chdir = /mpbackend +# Django's wsgi file +module = mpbackend.wsgi +# full path to python virtual env +home = /mpbackend/env +uid = appuser +gid = root +# enable uwsgi master process +master = true +# maximum number of worker processes +processes = 10 +# the socket (use the full path to be safe +socket = /mpbackend/mpbackend.sock +# socket permissions +chmod-socket = 666 +# clear environment on exit +vacuum = true + +# Set static path +static-map=/static=/mpbackend/static/ + +# daemonize uwsgi and write messages into given log +# daemonize = /mpbackend/uwsgi-emperor.log \ No newline at end of file diff --git a/deploy/requirements.txt b/deploy/requirements.txt new file mode 100644 index 0000000..a5e1f65 --- /dev/null +++ b/deploy/requirements.txt @@ -0,0 +1,2 @@ +-r ../requirements.txt +uwsgi \ No newline at end of file diff --git a/deploy/uwsgi_params b/deploy/uwsgi_params new file mode 100644 index 0000000..111ccc3 --- /dev/null +++ b/deploy/uwsgi_params @@ -0,0 +1,14 @@ +uwsgi_param QUERY_STRING $query_string; +uwsgi_param REQUEST_METHOD $request_method; +uwsgi_param CONTENT_TYPE $content_type; +uwsgi_param CONTENT_LENGTH $content_length; +uwsgi_param REQUEST_URI $request_uri; +uwsgi_param PATH_INFO $document_uri; +uwsgi_param DOCUMENT_ROOT $document_root; +uwsgi_param SERVER_PROTOCOL $server_protocol; +uwsgi_param REQUEST_SCHEME $scheme; +uwsgi_param HTTPS $https if_not_empty; +uwsgi_param REMOTE_ADDR $remote_addr; +uwsgi_param REMOTE_PORT $remote_port; +uwsgi_param SERVER_PORT $server_port; +uwsgi_param SERVER_NAME $server_name; \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..21ae3f7 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,34 @@ +version: '3.4' + +services: + + mpbackend: + build: + args: + - REQUIREMENTS_FILE=./deploy/requirements.txt + environment: + - DEBUG=false + + command: start_production_server + + nginx: + build: + dockerfile: ./docker/nginx/Dockerfile + context: . + ports: + - 80:80 + depends_on: + - mpbackend + networks: + - nginx_network + + volumes: + - mpbackend:/mpbackend + - static:/mpbackend/static +networks: + nginx_network: + driver: bridge + +volumes: + static: + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..62a1d48 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +version: '3.4' + +services: + postgres: + build: ./docker/postgres/ + environment: + - POSTGRES_USER=mobilityprofile + - POSTGRES_PASSWORD=mobilityprofile + - POSTGRES_DB=mobilityprofile + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + + mpbackend: + image: mpbackend + build: + context: . + dockerfile: ./docker/mpbackend/Dockerfile + args: + - REQUIREMENTS_FILE=requirements.txt + ports: + - 8000:8000 + environment: + - APPLY_MIGRATIONS=true + + command: start_django_development_server + + env_file: + - config_dev.env + volumes: + - mpbackend:/mpbackend + - static:/mpbackend/static/ + restart: on-failure + depends_on: + postgres: + condition: service_started + +volumes: + postgres-data: + mpbackend: + static: + + diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..5395888 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -e + +if [ -n "$DATABASE_HOST" ]; then + until nc -z -v -w30 "$DATABASE_HOST" 5432 + do + _log "Waiting for postgres database connection..." + sleep 1 + done + _log "Database is up!" +fi + +if [[ "$APPLY_MIGRATIONS" = "true" ]]; then + echo "Applying database migrations..." + ./manage.py migrate --noinput +fi + + +if [ "$DJANGO_SUPERUSER_USERNAME" ]; then + echo "Creating superuser if it does not exists." + python manage.py ensure_adminuser --username $DJANGO_SUPERUSER_USERNAME \ + --email $DJANGO_SUPERUSER_EMAIL \ + --password $DJANGO_SUPERUSER_PASSWORD +fi + +if [ "$1" = 'start_django_development_server' ]; then + # Start django develpoment server + echo "Starting development server." + ./manage.py runserver 0.0.0.0:8000 +elif [ "$1" = 'import_questions' ]; then + echo "Importing questions..." + ./manage.py import_questions +elif [ "$1" = 'start_production_server' ]; then + echo "Starting production server..." + exec uwsgi --ini deploy/docker_uwsgi.ini +fi + diff --git a/docker/mpbackend/Dockerfile b/docker/mpbackend/Dockerfile new file mode 100644 index 0000000..c79eb98 --- /dev/null +++ b/docker/mpbackend/Dockerfile @@ -0,0 +1,31 @@ +# For more information, please refer to https://aka.ms/vscode-docker-python +FROM ubuntu:22.04 +FROM python:3.10-slim + +EXPOSE 8000 +WORKDIR /mpbackend + +# Keeps Python from generating .pyc files in the container +ENV PYTHONDONTWRITEBYTECODE=1 + +# Turns off buffering for easier container logging +ENV PYTHONUNBUFFERED=1 +ENV STATIC_ROOT=/mpbackend/static +ENV STATIC_URL=/static/ +# tzdata installation requires settings frontend +RUN apt-get update && \ + TZ="Europe/Helsinki" DEBIAN_FRONTEND=noninteractive apt-get install -y python3-pip gdal-bin +ARG REQUIREMENTS_FILE +COPY . . +# Install pip requirements +RUN python -m pip install -r ${REQUIREMENTS_FILE} +RUN python manage.py collectstatic --no-input + + +# Creates a non-root user with an explicit UID and adds permission to access the /mpbackend folder +# For more info, please refer to https://aka.ms/vscode-docker-python-configure-containers +RUN adduser -u 5678 --disabled-password --gecos "" appuser && chown -R appuser /mpbackend +USER appuser + +ENTRYPOINT ["./docker-entrypoint.sh"] + diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile new file mode 100644 index 0000000..831777f --- /dev/null +++ b/docker/nginx/Dockerfile @@ -0,0 +1,12 @@ +FROM nginx:latest + + +COPY ./deploy/docker_nginx.conf /etc/nginx/nginx.conf +COPY ./deploy/uwsgi_params /etc/nginx/uwsgi_params + +# COPY ./deploy/docker_nginx.conf /etc/nginx/site-available/mpbackend.nginx.conf + +# RUN mkdir /etc/nginx/sites-enabled +# RUN ln -s /etc/nginx/sites-available/mpbackend.nginx.conf /etc/nginx/sites-enabled/ + + diff --git a/docker/postgres/Dockerfile b/docker/postgres/Dockerfile new file mode 100644 index 0000000..adf8e29 --- /dev/null +++ b/docker/postgres/Dockerfile @@ -0,0 +1,10 @@ +FROM postgres:14 + +RUN apt-get update && apt-get install --no-install-recommends -y \ + postgis postgresql-14-postgis-3 postgresql-14-postgis-3-scripts + +RUN localedef -i fi_FI -c -f UTF-8 -A /usr/share/locale/locale.alias fi_FI.UTF-8 + +ENV LANG fi_FI.UTF-8 + +COPY ./docker-entrypoint.sh /docker-entrypoint-initdb.d/docker-entrypoint.sh \ No newline at end of file diff --git a/docker/postgres/docker-entrypoint.sh b/docker/postgres/docker-entrypoint.sh new file mode 100644 index 0000000..be361c8 --- /dev/null +++ b/docker/postgres/docker-entrypoint.sh @@ -0,0 +1,2 @@ +#!/bin/bash +set -e \ No newline at end of file diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..1f2eeb3 --- /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", "mpbackend.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/media/questions.xlsx b/media/questions.xlsx new file mode 100644 index 0000000..8321925 Binary files /dev/null and b/media/questions.xlsx differ diff --git a/mpbackend/__init__.py b/mpbackend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mpbackend/asgi.py b/mpbackend/asgi.py new file mode 100644 index 0000000..afa4d13 --- /dev/null +++ b/mpbackend/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for mpbackend 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/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mpbackend.settings") + +application = get_asgi_application() diff --git a/mpbackend/authentication.py b/mpbackend/authentication.py new file mode 100644 index 0000000..5fd6803 --- /dev/null +++ b/mpbackend/authentication.py @@ -0,0 +1,37 @@ +from datetime import timedelta + +from django.conf import settings +from django.utils import timezone +from rest_framework.authentication import TokenAuthentication +from rest_framework.authtoken.models import Token +from rest_framework.exceptions import AuthenticationFailed + + +def is_token_expired(token): + min_age = timezone.now() - timedelta(hours=settings.TOKEN_EXPIRED_AFTER_HOURS) + expired = token.created < min_age + return expired + + +class ExpiringTokenAuthentication(TokenAuthentication): + """Same as in DRF, but also handle Token expiration. + An expired Token will be removed. + Raise AuthenticationFailed as needed, which translates + to a 401 status code automatically. + https://stackoverflow.com/questions/14567586 + """ + + def authenticate_credentials(self, key): + try: + token = Token.objects.get(key=key) + except Token.DoesNotExist: + raise AuthenticationFailed("Invalid token") + + if not token.user.is_active: + raise AuthenticationFailed("User inactive or deleted") + + if is_token_expired(token): + token.delete() + raise AuthenticationFailed("Token has expired and has been deleted") + + return (token.user, token) diff --git a/mpbackend/excluded_path.py b/mpbackend/excluded_path.py new file mode 100644 index 0000000..2b0c159 --- /dev/null +++ b/mpbackend/excluded_path.py @@ -0,0 +1,17 @@ +# https://stackoverflow.com/questions/68943065/django-drf-spectacular-can-you-exclude-specific-paths + +DOC_ENDPOINTS = [ + "/api/v1/question/", + "/api/v1/answer/", + "/api/v1/postalcoderesult/", + "/api/account/", +] + + +def preprocessing_filter_spec(endpoints): + filtered = [] + for endpoint in DOC_ENDPOINTS: + for path, path_regex, method, callback in endpoints: + if path.startswith(endpoint): + filtered.append((path, path_regex, method, callback)) + return filtered diff --git a/mpbackend/settings.py b/mpbackend/settings.py new file mode 100644 index 0000000..e9fc893 --- /dev/null +++ b/mpbackend/settings.py @@ -0,0 +1,253 @@ +import os +from pathlib import Path + +import environ +from django.conf.global_settings import LANGUAGES as GLOBAL_LANGUAGES +from django.core.exceptions import ImproperlyConfigured + +CONFIG_FILE_NAME = "config_dev.env" + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent +root = environ.Path(__file__) - 2 # two levels back in hierarchy + +env = environ.Env( + DEBUG=(bool, False), + LANGUAGES=(list, ["fi", "sv", "en"]), + DATABASE_URL=(str, "postgis:///servicemap"), + ALLOWED_HOSTS=(list, []), + EMAIL_BACKEND=(str, None), + EMAIL_HOST=(str, None), + EMAIL_HOST_USER=(str, None), + EMAIL_PORT=(int, None), + EMAIL_USE_TLS=(bool, None), + MEDIA_ROOT=(environ.Path(), root("media")), + STATIC_ROOT=(environ.Path(), root("static")), + MEDIA_URL=(str, "/media/"), + STATIC_URL=(str, "/static/"), + CORS_ORIGIN_WHITELIST=(list, []), +) +# WARN about env file not being preset. Here we pre-empt it. +env_file_path = os.path.join(BASE_DIR, CONFIG_FILE_NAME) +if os.path.exists(env_file_path): + # Logging configuration is not available at this point + print(f"Reading config from {env_file_path}") + environ.Env.read_env(env_file_path) + +DEBUG = env("DEBUG") +ALLOWED_HOSTS = env("ALLOWED_HOSTS") + + +# Custom user model +AUTH_USER_MODEL = "account.User" + +# Application definition +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "rest_framework.authtoken", + "django_extensions", + "modeltranslation", + "profiles.apps.ProfilesConfig", + "account.apps.AccountConfig", + "drf_spectacular", + "corsheaders", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "mpbackend.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", + ], + }, + }, +] + +CORS_ALLOW_HEADERS = [ + "X-CSRFTOKEN", + "csrftoken", + "Content-Type", + "Cookie", + "Authorization", +] +CORS_ALLOW_CREDENTIALS = True + +CORS_ORIGIN_WHITELIST = env("CORS_ORIGIN_WHITELIST") + +CSRF_TRUSTED_ORIGINS = ["http://localhost:8080"] + + +REST_FRAMEWORK = { + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticatedOrReadOnly" + ], + "DEFAULT_PAGINATION_CLASS": "profiles.api_pagination.Pagination", + "PAGE_SIZE": 20, + "DEFAULT_RENDERER_CLASSES": [ + "rest_framework.renderers.JSONRenderer", + "profiles.api.renderers.CustomBrowsableAPIRenderer", + ], + "DEFAULT_AUTHENTICATION_CLASSES": [ + "mpbackend.authentication.ExpiringTokenAuthentication", + ], + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + "rest_framework.throttling.UserRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": {"anon": "100/day", "user": "1000/day"}, +} + +WSGI_APPLICATION = "mpbackend.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases +DATABASES = {"default": env.db()} + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +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", + }, +] + +# Map language codes to the (code, name) tuples used by Django +# We want to keep the ordering in LANGUAGES configuration variable, +# thus some gyrations +language_map = {x: y for x, y in GLOBAL_LANGUAGES} +try: + LANGUAGES = tuple((lang, language_map[lang]) for lang in env("LANGUAGES")) +except KeyError as e: + raise ImproperlyConfigured(f'unknown language code "{e.args[0]}"') + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = env("LANGUAGES")[0] +MODELTRANSLATION_DEFAULT_LANGUAGE = LANGUAGE_CODE + +TIME_ZONE = "Europe/Helsinki" +USE_I18N = True +USE_L10N = True +USE_TZ = True +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ +# Static & Media files +STATIC_ROOT = env("STATIC_ROOT") +STATIC_URL = env("STATIC_URL") +MEDIA_ROOT = env("MEDIA_ROOT") +MEDIA_URL = env("MEDIA_URL") + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +if "SECRET_KEY" not in locals(): + secret_file = os.path.join(BASE_DIR, ".django_secret") + try: + SECRET_KEY = open(secret_file).read().strip() + except IOError: + import random + + system_random = random.SystemRandom() + try: + SECRET_KEY = "".join( + [ + system_random.choice( + "abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)" + ) + for i in range(64) + ] + ) + secret = open(secret_file, "w") + import os + + os.chmod(secret_file, 0o0600) + secret.write(SECRET_KEY) + secret.close() + except IOError: + Exception( + "Please create a %s file with random characters to generate your secret key!" + % secret_file + ) +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "timestamped_named": { + "format": "%(asctime)s %(name)s %(levelname)s: %(message)s", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "timestamped_named", + }, + # Just for reference, not used + "blackhole": {"class": "logging.NullHandler"}, + }, + "loggers": { + "django": {"handlers": ["console"], "level": "INFO"}, + "profiles": {"handlers": ["console"], "level": "DEBUG"}, + "django.security.DisallowedHost": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": False, + }, + }, +} + +EMAIL_BACKEND = env("EMAIL_BACKEND") +EMAIL_HOST = env("EMAIL_HOST") +EMAIL_HOST_USER = env("EMAIL_HOST_USER") +EMAIL_PORT = env("EMAIL_PORT") +EMAIL_USE_TLS = env("EMAIL_USE_TLS") + +SPECTACULAR_SETTINGS = { + "TITLE": "Mobility Profile API", + "DESCRIPTION": "Your project description", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + # OTHER SETTINGS + "PREPROCESSING_HOOKS": ["mpbackend.excluded_path.preprocessing_filter_spec"], +} + +# After how many hours users authentication token is expired and deleted +TOKEN_EXPIRED_AFTER_HOURS = 24 diff --git a/mpbackend/urls.py b/mpbackend/urls.py new file mode 100644 index 0000000..4d0048e --- /dev/null +++ b/mpbackend/urls.py @@ -0,0 +1,31 @@ +from django.contrib import admin +from django.urls import include, path, re_path +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) + +import account.api.urls +import profiles.api.urls +from profiles.views import get_csrf + +urlpatterns = [ + re_path("^admin/", admin.site.urls), + # re_path(r"^api/v1/", include(router.urls)), + re_path(r"^api/account/", include(account.api.urls), name="account"), + re_path(r"^api/v1/", include(profiles.api.urls), name="profiles"), + path("api/v1/schema/", SpectacularAPIView.as_view(), name="schema"), + path( + "api/v1/schema/swagger-ui/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="swagger-ui", + ), + path( + "api/v1/schema/redoc/", + SpectacularRedocView.as_view(url_name="schema"), + name="redoc", + ), + path("csrf/", get_csrf) + # path("api-auth/", include("rest_framework.urls")), +] diff --git a/mpbackend/wsgi.py b/mpbackend/wsgi.py new file mode 100644 index 0000000..1550923 --- /dev/null +++ b/mpbackend/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for mpbackend 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/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mpbackend.settings") + +application = get_wsgi_application() diff --git a/profiles/__init__.py b/profiles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/profiles/admin.py b/profiles/admin.py new file mode 100644 index 0000000..2b83b3a --- /dev/null +++ b/profiles/admin.py @@ -0,0 +1,96 @@ +from django.contrib import admin + +from profiles.models import ( + Answer, + Option, + PostalCode, + PostalCodeResult, + PostalCodeType, + Question, + QuestionCondition, + Result, + SubQuestion, + SubQuestionCondition, +) + + +class ReadOnlyFieldsAdminMixin: + def has_change_permission(self, request, obj=None): + return False + + +class DisableDeleteAdminMixin: + def has_delete_permission(self, request, obj=None): + return False + + +class QuestionAdmin(DisableDeleteAdminMixin, admin.ModelAdmin): + class Meta: + model = Question + + +class QuestionConditionAdmin( + DisableDeleteAdminMixin, ReadOnlyFieldsAdminMixin, admin.ModelAdmin +): + class Meta: + model = QuestionCondition + + +class SubQuestionAdmin(DisableDeleteAdminMixin, admin.ModelAdmin): + class Meta: + model = Question + + +class ResultAdmin(DisableDeleteAdminMixin, admin.ModelAdmin): + class Meta: + model = Result + + +class SubQuestionConditionAdmin( + DisableDeleteAdminMixin, ReadOnlyFieldsAdminMixin, admin.ModelAdmin +): + class Meta: + model = SubQuestionCondition + + +class OptionAdmin(DisableDeleteAdminMixin, ReadOnlyFieldsAdminMixin, admin.ModelAdmin): + class Meta: + model = Option + + +class AnswerAdmin(DisableDeleteAdminMixin, ReadOnlyFieldsAdminMixin, admin.ModelAdmin): + class Meta: + model = Answer + + +class PostalCodeAdmin( + DisableDeleteAdminMixin, ReadOnlyFieldsAdminMixin, admin.ModelAdmin +): + class Meta: + model = PostalCode + + +class PostalCodeTypeAdmin( + DisableDeleteAdminMixin, ReadOnlyFieldsAdminMixin, admin.ModelAdmin +): + class Meta: + model = PostalCodeType + + +class PostalCodeResultAdmin( + DisableDeleteAdminMixin, ReadOnlyFieldsAdminMixin, admin.ModelAdmin +): + class Meta: + model = PostalCodeResult + + +admin.site.register(Question, QuestionAdmin) +admin.site.register(QuestionCondition, QuestionConditionAdmin) +admin.site.register(SubQuestion, SubQuestionAdmin) +admin.site.register(SubQuestionCondition, SubQuestionConditionAdmin) +admin.site.register(Option, OptionAdmin) +admin.site.register(Result, ResultAdmin) +admin.site.register(Answer, AnswerAdmin) +admin.site.register(PostalCode, PostalCodeAdmin) +admin.site.register(PostalCodeType, PostalCodeTypeAdmin) +admin.site.register(PostalCodeResult, PostalCodeResultAdmin) diff --git a/profiles/api/__init__.py b/profiles/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/profiles/api/renderers.py b/profiles/api/renderers.py new file mode 100644 index 0000000..26868d0 --- /dev/null +++ b/profiles/api/renderers.py @@ -0,0 +1,10 @@ +from rest_framework import renderers + + +class CustomBrowsableAPIRenderer(renderers.BrowsableAPIRenderer): + def get_context(self, data, accepted_media_type, renderer_context): + context = super().get_context(data, accepted_media_type, renderer_context) + context["extra_actions"] = None + context["options_form"] = None + context["raw_data_post_form"] = None + return context diff --git a/profiles/api/serializers.py b/profiles/api/serializers.py new file mode 100644 index 0000000..6111612 --- /dev/null +++ b/profiles/api/serializers.py @@ -0,0 +1,126 @@ +from rest_framework import serializers + +from profiles.models import ( + Answer, + Option, + PostalCode, + PostalCodeResult, + PostalCodeType, + Question, + QuestionCondition, + Result, + SubQuestion, + SubQuestionCondition, +) + + +class ResultSerializer(serializers.ModelSerializer): + class Meta: + model = Result + fields = "__all__" + + +class OptionSerializer(serializers.ModelSerializer): + class Meta: + model = Option + fields = "__all__" + + def to_representation(self, obj): + representation = super().to_representation(obj) + representation["results"] = ResultSerializer(obj.results, many=True).data + return representation + + +class SubQuestionSerializer(serializers.ModelSerializer): + class Meta: + model = SubQuestion + fields = "__all__" + + def to_representation(self, obj): + representation = super().to_representation(obj) + representation["options"] = OptionSerializer(obj.options, many=True).data + representation["condition"] = SubQuestionConditionSerializer( + obj.sub_question_conditions, many=False + ).data + return representation + + +class QuestionSerializer(serializers.ModelSerializer): + class Meta: + model = Question + fields = "__all__" + + def to_representation(self, obj): + representation = super().to_representation(obj) + if hasattr(obj, "options") and obj.options.count() > 0: + representation["options"] = OptionSerializer(obj.options, many=True).data + elif hasattr(obj, "sub_questions") and obj.sub_questions.count() > 0: + representation["sub_questions"] = SubQuestionSerializer( + obj.sub_questions, many=True + ).data + + return representation + + +class QuestionRequestSerializer(serializers.Serializer): + question = serializers.IntegerField() + + +class QuestionsConditionsStatesSerializer(serializers.Serializer): + id = serializers.IntegerField() + state = serializers.BooleanField() + + +class SubQuestionRequestSerializer(serializers.Serializer): + sub_question = serializers.IntegerField() + + +class AnswerRequestSerializer(QuestionRequestSerializer): + option = serializers.IntegerField() + sub_question = serializers.IntegerField(required=False) + + +class InConditionResponseSerializer(serializers.Serializer): + in_condition = serializers.BooleanField() + + +class QuestionNumberIDSerializer(serializers.ModelSerializer): + class Meta: + model = Question + fields = ["id", "number"] + + +class QuestionConditionSerializer(serializers.ModelSerializer): + class Meta: + model = QuestionCondition + fields = "__all__" + + +class SubQuestionConditionSerializer(serializers.ModelSerializer): + class Meta: + model = SubQuestionCondition + fields = "__all__" + + +class AnswerSerializer(serializers.ModelSerializer): + class Meta: + model = Answer + fields = "__all__" + + +class PostalCodeResultSerializer(serializers.ModelSerializer): + class Meta: + model = PostalCodeResult + fields = "__all__" + + +class PostalCodeSerializer(serializers.ModelSerializer): + class Meta: + model = PostalCode + fields = "__all__" + + +class PostalCodeTypeSerializer(serializers.ModelSerializer): + class Meta: + model = PostalCodeType + fields = "__all__" diff --git a/profiles/api/urls.py b/profiles/api/urls.py new file mode 100644 index 0000000..dc8b3f1 --- /dev/null +++ b/profiles/api/urls.py @@ -0,0 +1,23 @@ +from django.urls import include, path +from rest_framework import routers + +from profiles.api.views import all_views + +app_name = "profiles" + +router = routers.DefaultRouter() +registered_api_views = set() +for view in all_views: + kwargs = {} + if view["name"] in registered_api_views: + continue + else: + registered_api_views.add(view["name"]) + + if "basename" in view: + kwargs["basename"] = view["basename"] + router.register(view["name"], view["class"], **kwargs) + +urlpatterns = [ + path("", include(router.urls), name="profiles"), +] diff --git a/profiles/api/views.py b/profiles/api/views.py new file mode 100644 index 0000000..8e76c8c --- /dev/null +++ b/profiles/api/views.py @@ -0,0 +1,686 @@ +import logging +import uuid + +from django.conf import settings +from django.contrib.auth.hashers import make_password +from django.db import IntegrityError, transaction +from django.utils.module_loading import import_string +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, + OpenApiParameter, + OpenApiResponse, +) +from rest_framework import status, viewsets +from rest_framework.authtoken.models import Token +from rest_framework.decorators import action +from rest_framework.mixins import CreateModelMixin +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from account.api.serializers import PublicUserSerializer +from account.models import Profile, User +from profiles.api.serializers import ( + AnswerRequestSerializer, + AnswerSerializer, + InConditionResponseSerializer, + OptionSerializer, + PostalCodeResultSerializer, + PostalCodeSerializer, + PostalCodeTypeSerializer, + QuestionConditionSerializer, + QuestionNumberIDSerializer, + QuestionRequestSerializer, + QuestionsConditionsStatesSerializer, + QuestionSerializer, + ResultSerializer, + SubQuestionConditionSerializer, + SubQuestionRequestSerializer, + SubQuestionSerializer, +) +from profiles.models import ( + Answer, + Option, + PostalCode, + PostalCodeResult, + PostalCodeType, + Question, + QuestionCondition, + Result, + SubQuestion, + SubQuestionCondition, +) +from profiles.utils import generate_password, get_user_result + +logger = logging.getLogger(__name__) + +DEFAULT_RENDERERS = [ + import_string(renderer_module) + for renderer_module in settings.REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] +] +all_views = [] + + +def register_view(klass, name, basename=None): + entry = {"class": klass, "name": name} + if basename is not None: + entry["basename"] = basename + all_views.append(entry) + + +def get_or_create_row(model, filter): + results = model.objects.filter(**filter) + if results.exists(): + return results.first(), False + else: + return model.objects.create(**filter), True + + +def sub_question_condition_met(sub_question_condition, user): + if ( + Answer.objects.filter(user=user, option=sub_question_condition.option).count() + > 0 + ): + return True + return False + + +def question_condition_met(question_condition_qs, user): + for question_condition in question_condition_qs: + if question_condition.sub_question_condition: + user_answers = Answer.objects.filter( + user=user, + option__sub_question=question_condition.sub_question_condition, + ).values_list("option", flat="True") + else: + user_answers = Answer.objects.filter( + user=user, option__question=question_condition.question_condition + ).values_list("option", flat="True") + + option_conditions = question_condition.option_conditions.all().values_list( + "id", flat=True + ) + if set(user_answers).intersection(set(option_conditions)): + return True + return False + + +@transaction.atomic +def update_postal_code_result(user): + # Ensure that duplicate results are not saved, profiles filled for fun and profiles whos result + # can not be used are ignored. + + if ( + user.postal_code_result_saved + or user.profile.is_filled_for_fun + or not user.profile.result_can_be_used + ): + return + if user.result: + result = user.result + else: + result = get_user_result(user) + if not result: + return + postal_code = None + postal_code_type = None + if user.profile.postal_code: + postal_code, _ = PostalCode.objects.get_or_create( + postal_code=user.profile.postal_code + ) + postal_code_type, _ = PostalCodeType.objects.get_or_create( + type_name=PostalCodeType.HOME_POSTAL_CODE + ) + if user.profile.optional_postal_code: + postal_code, _ = PostalCode.objects.get_or_create( + postal_code=user.profile.optional_postal_code + ) + postal_code_type, _ = PostalCodeType.objects.get_or_create( + type_name=PostalCodeType.OPTIONAL_POSTAL_CODE + ) + + try: + postal_code_result, _ = PostalCodeResult.objects.get_or_create( + postal_code=postal_code, postal_code_type=postal_code_type, result=result + ) + except IntegrityError as e: + logger.error(f"IntegrityError while creating PostalCodeResult: {e}") + return + postal_code_result.count += 1 + postal_code_result.save() + user.postal_code_result_saved = True + user.save() + + +class QuestionViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Question.objects.all() + serializer_class = QuestionSerializer + renderer_classes = DEFAULT_RENDERERS + + @extend_schema( + description="Start the Poll for a anonymous user. Creates a anonymous user and logs the user in." + " Returns the id of the user.", + parameters=[], + examples=None, + request=None, + responses={200: PublicUserSerializer}, + ) + @action( + detail=False, + methods=["POST"], + permission_classes=[AllowAny], + ) + def start_poll(self, request): + # TODO check recaptha + uuid4 = uuid.uuid4() + username = f"anonymous_{str(uuid4)}" + user = User.objects.create(pk=uuid4, username=username, is_generated=True) + password = make_password(generate_password()) + user.password = password + user.profile = Profile.objects.create(user=user) + user.save() + token, _ = Token.objects.get_or_create(user=user) + response_data = {"token": token.key, "id": user.id} + return Response(response_data, status=status.HTTP_200_OK) + + @extend_schema( + description="Return the numbers of questions", + parameters=[], + examples=None, + responses={200: QuestionNumberIDSerializer(many=True)}, + ) + @action( + detail=False, + methods=["GET"], + ) + def get_question_numbers(self, request): + queryset = Question.objects.all().order_by("id") + page = self.paginate_queryset(queryset) + serializer = QuestionNumberIDSerializer(page, many=True) + return self.get_paginated_response(serializer.data) + + @extend_schema( + description="Returns the questions that have a condition.", + parameters=[], + examples=None, + responses={200: QuestionSerializer(many=True)}, + ) + @action( + detail=False, + methods=["GET"], + ) + def get_questions_with_conditions(self, request): + queryset = Question.objects.filter(question_conditions__isnull=False) + page = self.paginate_queryset(queryset) + serializer = QuestionSerializer(page, many=True) + return self.get_paginated_response(serializer.data) + + @extend_schema( + description="Returns current state of condition for all questions that have a condition." + "If true, the condition has been met and can be displayed for the user.", + parameters=[], + examples=None, + responses={ + 200: OpenApiResponse( + description="List of states, containing the question ID and the state." + ) + }, + ) + @action(detail=False, methods=["GET"], permission_classes=[IsAuthenticated]) + def get_questions_conditions_states(self, request): + + questions_with_cond_qs = Question.objects.filter( + question_conditions__isnull=False + ) + user = request.user + states = [] + for question in questions_with_cond_qs: + question_condition_qs = QuestionCondition.objects.filter(question=question) + state = {"id": question.id} + state["state"] = question_condition_met(question_condition_qs, user) + states.append(state) + serializer = QuestionsConditionsStatesSerializer(data=states, many=True) + if serializer.is_valid(): + validated_data = serializer.validated_data + return Response(validated_data) + else: + return Response(serializer.errors, status=400) + + @action( + detail=False, + methods=["GET"], + ) + def get_question(self, request): + number = request.query_params.get("number", None) + if number: + try: + question = Question.objects.get(number=number) + except Question.DoesNotExist: + return Response( + f"question with number {number} not found", + status=status.HTTP_404_NOT_FOUND, + ) + serializer = QuestionSerializer(question) + return Response(serializer.data, status=status.HTTP_200_OK) + + else: + return Response( + "'number' argument not given", status=status.HTTP_400_BAD_REQUEST + ) + + @extend_schema( + description="Ends the poll for the user by logging out the user. Updates also the PostalCodeResult table." + "Must be called after poll is finnished.", + parameters=[], + examples=None, + responses={200: None}, + request=None, + ) + @action(detail=False, methods=["POST"], permission_classes=[IsAuthenticated]) + def end_poll(self, request): + user = request.user + update_postal_code_result(user) + user.auth_token.delete() + return Response("Poll ended.", status=status.HTTP_200_OK) + + @extend_schema( + description="Checks if condition met. Returns 'true' if the user has answered the given conditions " + "of the question in such a way that the given question should be asked. " + "The information of the if the condition is met, should be fetched before Every question, except the first", + request=QuestionRequestSerializer, + responses={ + 200: OpenApiResponse(description="true or false"), + 400: OpenApiResponse(description="'question' argument not given"), + 404: OpenApiResponse( + description="'Question' or 'QuestionCondition' row not found" + ), + }, + ) + @action(detail=False, methods=["POST"], permission_classes=[IsAuthenticated]) + def check_if_question_condition_met(self, request): + user = request.user + question_id = request.data.get("question", None) + if not question_id: + return Response( + "'question_id' argument not given", + status=status.HTTP_400_BAD_REQUEST, + ) + else: + try: + question = Question.objects.get(id=question_id) + except Question.DoesNotExist: + return Response( + f"Question {question_id} not found", + status=status.HTTP_404_NOT_FOUND, + ) + # Retrive the conditions for the question, note can have multiple conditions + question_condition_qs = QuestionCondition.objects.filter(question=question) + if question_condition_qs.count() == 0: + return Response( + f"QuestionCondition not found for question number {question_id}", + status=status.HTTP_404_NOT_FOUND, + ) + if question_condition_met(question_condition_qs, user): + return Response({"condition_met": True}, status=status.HTTP_200_OK) + + return Response({"condition_met": False}, status=status.HTTP_200_OK) + + @extend_schema( + description="Checks if condition met for a sub question." + "Returns 'true', if the condition is met or there is no condition." + "Returns 'false', if the user has answered previous questions in way," + " that the sub question should not be questioned/displayed for the user.", + request=SubQuestionRequestSerializer, + responses={ + 200: OpenApiResponse(description="true or false"), + 400: OpenApiResponse(description="'sub_question' argument not given"), + 404: OpenApiResponse( + description="'SubQuestion' or 'SubQuestionCondition' row not found" + ), + }, + ) + @action(detail=False, methods=["POST"], permission_classes=[IsAuthenticated]) + def check_if_sub_question_condition_met(self, request): + user = request.user + sub_question_id = request.data.get("sub_question", None) + if not sub_question_id: + return Response( + "'sub_question_id' argument not given", + status=status.HTTP_400_BAD_REQUEST, + ) + else: + try: + sub_question = SubQuestion.objects.get(id=sub_question_id) + except SubQuestion.DoesNotExist: + return Response( + f"Question {sub_question_id} not found", + status=status.HTTP_404_NOT_FOUND, + ) + sub_question_condition = SubQuestionCondition.objects.filter( + sub_question=sub_question + ).first() + # Condition is met if no condition is found + condition_met = True + if sub_question_condition: + if not sub_question_condition_met(sub_question_condition, user): + condition_met = False + + return Response({"condition_met": condition_met}, status=status.HTTP_200_OK) + + @extend_schema( + description="Check if question is in condition. If the question is not in a condition" + ", it is not required to post the answers of the question immediately after they are given.", + examples=None, + request=QuestionRequestSerializer, + responses={ + 200: InConditionResponseSerializer, + 404: OpenApiResponse(description="'Question' not found"), + }, + ) + @action( + detail=False, + methods=["POST"], + permission_classes=[IsAuthenticated], + ) + def in_condition(self, request, *args, **kwargs): + question_id = request.data.get("question", None) + response_data = {"in_condition": False} + + if not question_id: + return Response( + "'question_id' argument not given", + status=status.HTTP_400_BAD_REQUEST, + ) + else: + try: + question = Question.objects.get(id=question_id) + except Question.DoesNotExist: + return Response( + f"Question {question_id} not found", + status=status.HTTP_404_NOT_FOUND, + ) + + qs = QuestionCondition.objects.filter(question_condition=question) + if qs.count() > 0: + response_data["in_condition"] = True + serializer = InConditionResponseSerializer(data=response_data) + if serializer.is_valid(): + return Response(serializer.validated_data, status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +register_view(QuestionViewSet, "question") + + +class QuestionConditionViewSet(viewsets.ReadOnlyModelViewSet): + queryset = QuestionCondition.objects.all() + serializer_class = QuestionConditionSerializer + + +register_view(QuestionConditionViewSet, "questioncondition") + + +class SubQuestionConditionViewSet(viewsets.ReadOnlyModelViewSet): + queryset = SubQuestionCondition.objects.all() + serializer_class = SubQuestionConditionSerializer + + +register_view(SubQuestionConditionViewSet, "subquestioncondition") + + +class OptionViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Option.objects.all() + serializer_class = OptionSerializer + + +register_view(OptionViewSet, "option") + + +class SubQuestionViewSet(viewsets.ReadOnlyModelViewSet): + queryset = SubQuestion.objects.all() + serializer_class = SubQuestionSerializer + + +register_view(SubQuestionViewSet, "subquestion") + + +class ResultViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Result.objects.all() + serializer_class = ResultSerializer + + +register_view(ResultViewSet, "result") + + +class AnswerViewSet(CreateModelMixin, GenericViewSet): + queryset = Answer.objects.all() + serializer_class = AnswerSerializer + renderer_classes = DEFAULT_RENDERERS + + def get_permissions(self): + if self.action in ["list", "retrieve"]: + permission_classes = [AllowAny] + else: + permission_classes = [IsAuthenticated] + return [permission() for permission in permission_classes] + + @extend_schema( + description="Create an answer for the user that is logged in." + "Note, if same user, question and optionally sub question is given the answer will be" + " updated with the new option.", + request=AnswerRequestSerializer, + responses={ + 201: OpenApiResponse(description="created"), + 400: OpenApiResponse( + description="'option' or 'question' argument not given" + ), + 404: OpenApiResponse( + description="'option', 'question' or 'sub_question' not found" + ), + 405: OpenApiResponse( + description="Question or sub question condition not met," + " i.e. the user has answered so that this question cannot be answered" + ), + 500: OpenApiResponse(description="Not created"), + }, + ) + def create(self, request, *args, **kwargs): + user = request.user + option_id = request.data.get("option", None) + question_id = request.data.get("question", None) + sub_question_id = request.data.get("sub_question", None) + sub_question = None + if not option_id: + return Response( + "'option' argument not given", status=status.HTTP_400_BAD_REQUEST + ) + + if not question_id: + return Response( + "'question' argument not given", status=status.HTTP_400_BAD_REQUEST + ) + try: + question = Question.objects.get(id=question_id) + except Question.DoesNotExist: + return Response( + f"Question {question_id} not found", status=status.HTTP_404_NOT_FOUND + ) + if question.num_sub_questions > 0: + try: + sub_question = SubQuestion.objects.get( + id=sub_question_id, question=question + ) + except SubQuestion.DoesNotExist: + return Response( + f"SubQuestion {sub_question_id} not found or wrong related question.", + status=status.HTTP_404_NOT_FOUND, + ) + + if sub_question: + try: + option = Option.objects.get(id=option_id, sub_question=sub_question) + except Option.DoesNotExist: + return Response( + f"Option {option_id} not found or wrong related or sub_question.", + status=status.HTTP_404_NOT_FOUND, + ) + else: + try: + option = Option.objects.get(id=option_id, question=question) + except Option.DoesNotExist: + return Response( + f"Option {option_id} not found or wrong related question.", + status=status.HTTP_404_NOT_FOUND, + ) + + question_condition_qs = QuestionCondition.objects.filter(question=question) + if question_condition_qs.count() > 0: + if not question_condition_met(question_condition_qs, user): + return Response( + "Question condition not met, i.e. the user has answered so that this question cannot be answered", + status=status.HTTP_405_METHOD_NOT_ALLOWED, + ) + sub_question_condition = SubQuestionCondition.objects.filter( + sub_question=sub_question + ).first() + if sub_question_condition: + if not sub_question_condition_met(sub_question_condition, user): + return Response( + "SubQuestion condition not met, " + "i.e. the user has answered so that this sub question cannot be answered", + status=status.HTTP_405_METHOD_NOT_ALLOWED, + ) + if user: + filter = {"user": user, "question": question, "sub_question": sub_question} + queryset = Answer.objects.filter(**filter) + if queryset.count() == 0: + filter["option"] = option + Answer.objects.create(**filter) + else: + # Update existing answer + answer = queryset.first() + answer.option = option + answer.save() + return Response(status=status.HTTP_201_CREATED) + else: + return Response("Not created", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @extend_schema( + description="Return the current result(animal) of the authenticated user", + examples=None, + responses={200: ResultSerializer}, + ) + @action( + detail=False, + methods=["GET"], + permission_classes=[IsAuthenticated], + ) + def get_result(self, request, *args, **kwargs): + user = request.user + if not user.is_authenticated: + return Response( + "No authentication credentials were provided in the request.", + status=status.HTTP_403_FORBIDDEN, + ) + result = get_user_result(user) + serializer = ResultSerializer(result) + return Response(serializer.data, status=status.HTTP_200_OK) + + +register_view(AnswerViewSet, "answer") + +POSTAL_CODE_PARAM = OpenApiParameter( + name="postal_code", + location=OpenApiParameter.QUERY, + description="'id' of the PostalCode instance. Empty value is treated as null and results for " + "user answers that have not provided a postal code will be returned", + required=False, + type=int, +) +POSTAL_CODE_TYPE_PARAM = OpenApiParameter( + name="postal_code_type", + location=OpenApiParameter.QUERY, + description="'id' of the PostalCodeType instance. Empty value is treated as null", + required=False, + type=int, +) + + +@extend_schema_view( + list=extend_schema( + parameters=[POSTAL_CODE_PARAM, POSTAL_CODE_TYPE_PARAM], + description="Returns aggregated results per postal code and/or postal code type.", + ) +) +class PostalCodeResultViewSet(viewsets.ReadOnlyModelViewSet): + queryset = PostalCodeResult.objects.all() + serializer_class = PostalCodeResultSerializer + + def list(self, request, *args, **kwargs): + queryset = None + qs1 = None + qs2 = None + postal_code_id = request.query_params.get("postal_code", None) + postal_code_type_id = request.query_params.get("postal_code_type", None) + if postal_code_id: + try: + postal_code = PostalCode.objects.get(id=postal_code_id) + except PostalCode.DoesNotExist: + return Response( + f"PostalCode {postal_code_id} not found", + status=status.HTTP_404_NOT_FOUND, + ) + else: + # Postal code is not mandatory + postal_code = None + + if postal_code_type_id: + try: + postal_code_type = PostalCodeType.objects.get(id=postal_code_type_id) + except PostalCodeType.DoesNotExist: + return Response( + f"PostalCodeType {postal_code_type_id} not found", + status=status.HTTP_404_NOT_FOUND, + ) + else: + # Postal code type is not mandatory + postal_code_type = None + + # Make possible to query with None value in query params + # as all users do not provide a postal code + if "postal_code" in request.query_params: + qs1 = PostalCodeResult.objects.filter(postal_code=postal_code) + if "postal_code_type" in request.query_params: + qs2 = PostalCodeResult.objects.filter(postal_code_type=postal_code_type) + + if qs1 and qs2: + queryset = qs1.intersection(qs2) + elif qs1 or qs2: + queryset = qs1 if qs1 else qs2 + else: + queryset = self.queryset + + page = self.paginate_queryset(queryset) + serializer = self.serializer_class(page, many=True) + return self.get_paginated_response(serializer.data) + + +register_view(PostalCodeResultViewSet, "postalcoderesult") + + +class PostalCodeViewSet(viewsets.ReadOnlyModelViewSet): + queryset = PostalCode.objects.all() + serializer_class = PostalCodeSerializer + + +register_view(PostalCodeViewSet, "postalcode") + + +class PostalCodeTypeViewSet(viewsets.ReadOnlyModelViewSet): + queryset = PostalCodeType.objects.all() + serializer_class = PostalCodeTypeSerializer + + +register_view(PostalCodeViewSet, "postalcodetype") diff --git a/profiles/api_pagination.py b/profiles/api_pagination.py new file mode 100644 index 0000000..3439cbe --- /dev/null +++ b/profiles/api_pagination.py @@ -0,0 +1,6 @@ +from rest_framework.pagination import PageNumberPagination + + +class Pagination(PageNumberPagination): + page_size_query_param = "page_size" + max_page_size = 1000 diff --git a/profiles/apps.py b/profiles/apps.py new file mode 100644 index 0000000..4067b98 --- /dev/null +++ b/profiles/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class ProfilesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "profiles" + + def ready(self): + # register signals + from profiles import signals # noqa: F401 diff --git a/profiles/management/commands/import_questions.py b/profiles/management/commands/import_questions.py new file mode 100644 index 0000000..0039149 --- /dev/null +++ b/profiles/management/commands/import_questions.py @@ -0,0 +1,274 @@ +import logging +from typing import Any + +import pandas as pd +from django import db +from django.conf import settings +from django.core.management import BaseCommand + +from profiles.models import ( + Option, + Question, + QuestionCondition, + Result, + SubQuestion, + SubQuestionCondition, +) + +logger = logging.getLogger(__name__) +FILENAME = "questions.xlsx" +IS_ANIMAL = 1 + +LANGUAGES = [language[0] for language in settings.LANGUAGES] +LANGUAGE_SEPARATOR = "/" +QUESTION_NUMBER_COLUMN = 0 +QUESTION_COLUMN = 1 +NUMBER_OF_OPTIONS_TO_CHOOSE = 2 +CONDITION_COLUMN = 3 +QUESTION_DESCRIPTION_COLUMN = 4 +SUB_QUESTION_COLUMN = 5 +MANDATORY_NUMBER_OF_SUB_QUESTIONS_TO_ANSWER_COLUMN = 6 +SUB_QUESTION_DESCRIPTION_COLUMN = 7 +SUB_QUESTION_CONDITION_COLUMN = 8 + +OPTION_COLUMN = 9 +RESULT_COLUMNS = [10, 11, 12, 13, 14, 15] + + +def get_root_dir() -> str: + """ + Returns the root directory of the project. + """ + if hasattr(settings, "PROJECT_ROOT"): + return settings.PROJECT_ROOT + else: + return settings.BASE_DIR + + +def save_translated_field(obj: Any, field_name: str, data: dict): + """ + Sets the value of all languages for given field_name. + :param obj: the object to which the fields will be set + :param field_name: name of the field to be set. + :param data: dictionary where the key is the language and the value is the value + to be set for the field with the given langauge. + """ + for lang in LANGUAGES: + if lang in data: + obj_key = "{}_{}".format(field_name, lang) + setattr(obj, obj_key, data[lang]) + obj.save() + + +def get_language_dict(data: str) -> dict: + data = str(data).split(LANGUAGE_SEPARATOR) + d = {} + for i, lang in enumerate(LANGUAGES): + if i < len(data): + try: + d[lang] = data[i].strip() + except AttributeError as e: + logger.error(f"AttributeError {e}") + else: + d[lang] = None + return d + + +@db.transaction.atomic +def get_and_create_results(data: pd.DataFrame) -> list: + results_to_delete = list(Result.objects.all().values_list("id", flat=True)) + num_created = 0 + + columns = data.columns[RESULT_COLUMNS[0] : RESULT_COLUMNS[-1] + 1] + results = [] + for i, column in enumerate(columns): + col_data = data[column] + topic = get_language_dict(data.columns[RESULT_COLUMNS[0] + i]) + value = get_language_dict(col_data[1]) + description = get_language_dict(col_data[0]) + filter = {} + for lang in LANGUAGES: + filter[f"topic_{lang}"] = topic[lang] + filter[f"value_{lang}"] = value[lang] + filter[f"description_{lang}"] = description[lang] + queryset = Result.objects.filter(**filter) + if queryset.count() == 0: + result = Result.objects.create(**filter) + logger.info(f"Created Result: {topic['fi']}") + num_created += 1 + else: + result = queryset.first() + id = queryset.first().id + if id in results_to_delete: + results_to_delete.remove(id) + + results.append(result) + Result.objects.filter(id__in=results_to_delete).delete() + logger.info(f"Created {num_created} Results") + return results + + +@db.transaction.atomic +def create_sub_question_condition(row_data: str, sub_question: SubQuestion): + question_number, option_order_number = row_data.split(".") + question = Question.objects.get(number=question_number) + option = Option.objects.get(question=question, order_number=option_order_number) + SubQuestionCondition.objects.create(sub_question=sub_question, option=option) + + +@db.transaction.atomic +def create_conditions(row_data: str, question: Question): + # Hack as e.g. "7,1" in excel cell is interpreted as float even though it is formated to str in excel. + if type(row_data) != str: + row_data = str(row_data).replace(".", ",") + question_separator = ":" + option_separator = "," + question_sub_question_separator = "." + conditions = row_data.split(question_separator) + for cond in conditions: + if option_separator in cond: + question_subquestion, options = cond.split(option_separator) + options = options.split("-") + else: + question_subquestion = cond + options = [] + tmp = question_subquestion.split(question_sub_question_separator) + question_number = tmp[0] + question_condition = Question.objects.get(number=question_number) + sub_question_condition = None + if len(tmp) == 2: + sub_question_order_number = tmp[1] + sub_question_condition = SubQuestion.objects.get( + question=question_condition, order_number=sub_question_order_number + ) + options_qs = Option.objects.filter( + sub_question=sub_question_condition, order_number__in=options + ) + else: + options_qs = Option.objects.filter( + question=question_condition, order_number__in=options + ) + + question_condition, created = QuestionCondition.objects.get_or_create( + question=question, + question_condition=question_condition, + sub_question_condition=sub_question_condition, + ) + if created: + question_condition.option_conditions.add(*options_qs) + + +@db.transaction.atomic +def save_questions(excel_data: pd.DataFrame, results: list): + question = None + sub_question = None + sub_question_order_number = None + option_order_number = None + num_created = 0 + questions_to_delete = list(Question.objects.all().values_list("id", flat=True)) + for index, row_data in excel_data.iterrows(): + # The end of the questions sheet includes questions that will not be imported. + if index > 214: + break + try: + question_number = str(row_data[QUESTION_NUMBER_COLUMN]) + except TypeError: + continue + + if question_number[0].isdigit(): + questions = get_language_dict(row_data[QUESTION_COLUMN]) + descriptions = get_language_dict(row_data[QUESTION_DESCRIPTION_COLUMN]) + number_of_options_to_choose = row_data[NUMBER_OF_OPTIONS_TO_CHOOSE] + if not number_of_options_to_choose: + number_of_options_to_choose = "1" + + mandatory_number_of_sub_questions_to_answer = row_data[ + MANDATORY_NUMBER_OF_SUB_QUESTIONS_TO_ANSWER_COLUMN + ] + if not mandatory_number_of_sub_questions_to_answer: + mandatory_number_of_sub_questions_to_answer = "*" + filter = { + "number": question_number, + "number_of_options_to_choose": str(number_of_options_to_choose), + "mandatory_number_of_sub_questions_to_answer": str( + mandatory_number_of_sub_questions_to_answer + ).replace(".0", ""), + } + for lang in LANGUAGES: + filter[f"question_{lang}"] = questions[lang] + filter[f"description_{lang}"] = descriptions[lang] + + queryset = Question.objects.filter(**filter) + if queryset.count() == 0: + question = Question.objects.create(**filter) + logger.info(f"Created question: {questions['fi']}") + num_created += 1 + else: + logger.info(f"Found question: {questions['fi']}") + question = queryset.first() + id = queryset.first().id + if id in questions_to_delete: + questions_to_delete.remove(id) + if row_data[CONDITION_COLUMN]: + create_conditions(row_data[CONDITION_COLUMN], question) + sub_question_order_number = 0 + option_order_number = 0 + sub_question = None + + # Create SubQuestion + if question and row_data[SUB_QUESTION_COLUMN]: + logger.info(f"created sub question {row_data[SUB_QUESTION_COLUMN]}") + sub_question, _ = SubQuestion.objects.get_or_create( + question=question, order_number=sub_question_order_number + ) + sub_question_order_number += 1 + option_order_number = 0 + q_str = row_data[SUB_QUESTION_COLUMN] + save_translated_field(sub_question, "description", get_language_dict(q_str)) + desc_str = row_data[SUB_QUESTION_DESCRIPTION_COLUMN] + if desc_str: + save_translated_field( + sub_question, + "additional_description", + get_language_dict(desc_str), + ) + if row_data[SUB_QUESTION_CONDITION_COLUMN]: + create_sub_question_condition( + row_data[SUB_QUESTION_CONDITION_COLUMN], sub_question + ) + + # Create option + if question or sub_question and row_data[OPTION_COLUMN]: + val_str = row_data[OPTION_COLUMN] + if sub_question: + option, _ = Option.objects.get_or_create( + sub_question=sub_question, order_number=option_order_number + ) + else: + # Skips rows with category info + if not val_str: + continue + option, _ = Option.objects.get_or_create( + question=question, order_number=option_order_number + ) + + option_order_number += 1 + save_translated_field(option, "value", get_language_dict(val_str)) + for a_i, a_c in enumerate(RESULT_COLUMNS): + if row_data[a_c] == IS_ANIMAL: + option.results.add(results[a_i]) + Question.objects.filter(id__in=questions_to_delete).delete() + logger.info(f"Created {num_created} questions") + + +class Command(BaseCommand): + def handle(self, *args, **options): + # Question.objects.all().delete() + # QuestionCondition.objects.all().delete() + # Result.objects.all().delete() + + file_path = f"{get_root_dir()}/media/{FILENAME}" + excel_data = pd.read_excel(file_path, sheet_name="Yhdistetty") + excel_data = excel_data.fillna("").replace([""], [None]) + results = get_and_create_results(excel_data) + save_questions(excel_data, results) diff --git a/profiles/migrations/0001_initial.py b/profiles/migrations/0001_initial.py new file mode 100644 index 0000000..bc30482 --- /dev/null +++ b/profiles/migrations/0001_initial.py @@ -0,0 +1,255 @@ +# Generated by Django 4.1.10 on 2023-09-20 09:29 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Option", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("value", models.CharField(max_length=255, null=True)), + ("value_fi", models.CharField(max_length=255, null=True)), + ("value_sv", models.CharField(max_length=255, null=True)), + ("value_en", models.CharField(max_length=255, null=True)), + ("affect_result", models.BooleanField(default=True)), + ("order_number", models.PositiveSmallIntegerField(null=True)), + ], + options={ + "ordering": ["question__number", "sub_question__question__number"], + }, + ), + migrations.CreateModel( + name="Question", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("number", models.CharField(max_length=3, null=True)), + ("question", models.CharField(max_length=255, null=True)), + ("question_fi", models.CharField(max_length=255, null=True)), + ("question_sv", models.CharField(max_length=255, null=True)), + ("question_en", models.CharField(max_length=255, null=True)), + ("description", models.CharField(max_length=255, null=True)), + ("description_fi", models.CharField(max_length=255, null=True)), + ("description_sv", models.CharField(max_length=255, null=True)), + ("description_en", models.CharField(max_length=255, null=True)), + ("number_of_choices", models.CharField(default="1", max_length=2)), + ( + "number_of_sub_question_choices", + models.PositiveSmallIntegerField(default=1), + ), + ], + options={ + "ordering": ["number"], + }, + ), + migrations.CreateModel( + name="Result", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("topic", models.CharField(max_length=64, null=True)), + ("topic_fi", models.CharField(max_length=64, null=True)), + ("topic_sv", models.CharField(max_length=64, null=True)), + ("topic_en", models.CharField(max_length=64, null=True)), + ("value", models.CharField(max_length=64, null=True)), + ("value_fi", models.CharField(max_length=64, null=True)), + ("value_sv", models.CharField(max_length=64, null=True)), + ("value_en", models.CharField(max_length=64, null=True)), + ("description", models.TextField(null=True)), + ("description_fi", models.TextField(null=True)), + ("description_sv", models.TextField(null=True)), + ("description_en", models.TextField(null=True)), + ], + options={ + "ordering": ["id"], + }, + ), + migrations.CreateModel( + name="SubQuestion", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("description", models.CharField(max_length=255, null=True)), + ("description_fi", models.CharField(max_length=255, null=True)), + ("description_sv", models.CharField(max_length=255, null=True)), + ("description_en", models.CharField(max_length=255, null=True)), + ("additional_description", models.CharField(max_length=255, null=True)), + ( + "additional_description_fi", + models.CharField(max_length=255, null=True), + ), + ( + "additional_description_sv", + models.CharField(max_length=255, null=True), + ), + ( + "additional_description_en", + models.CharField(max_length=255, null=True), + ), + ("order_number", models.PositiveSmallIntegerField(null=True)), + ( + "question", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="sub_questions", + to="profiles.question", + ), + ), + ], + options={ + "ordering": ["question__number"], + }, + ), + migrations.CreateModel( + name="QuestionCondition", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "option_conditions", + models.ManyToManyField( + related_name="option_conditions", to="profiles.option" + ), + ), + ( + "question", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="conditions", + to="profiles.question", + ), + ), + ( + "question_condition", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="question_conditions", + to="profiles.question", + ), + ), + ( + "sub_question_condition", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="sub_question_conditions", + to="profiles.subquestion", + ), + ), + ], + ), + migrations.AddField( + model_name="option", + name="question", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="options", + to="profiles.question", + ), + ), + migrations.AddField( + model_name="option", + name="results", + field=models.ManyToManyField(related_name="options", to="profiles.result"), + ), + migrations.AddField( + model_name="option", + name="sub_question", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="options", + to="profiles.subquestion", + ), + ), + migrations.CreateModel( + name="Answer", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ( + "option", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="answers", + to="profiles.option", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="answers", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["id"], + }, + ), + migrations.AddConstraint( + model_name="answer", + constraint=models.UniqueConstraint( + fields=("user", "option"), name="unique_user_and_option" + ), + ), + ] diff --git a/profiles/migrations/0002_add_mandatory_number_of_sub_question_answers_to_question.py b/profiles/migrations/0002_add_mandatory_number_of_sub_question_answers_to_question.py new file mode 100644 index 0000000..f62d9df --- /dev/null +++ b/profiles/migrations/0002_add_mandatory_number_of_sub_question_answers_to_question.py @@ -0,0 +1,21 @@ +# Generated by Django 4.1.10 on 2023-09-21 09:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("profiles", "0001_initial"), + ] + + operations = [ + migrations.RemoveField( + model_name="question", + name="number_of_sub_question_choices", + ), + migrations.AddField( + model_name="question", + name="mandatory_number_of_sub_questions_to_answer", + field=models.CharField(default="*", max_length=2), + ), + ] diff --git a/profiles/migrations/0003_add_question_and_sub_question_to_answer.py b/profiles/migrations/0003_add_question_and_sub_question_to_answer.py new file mode 100644 index 0000000..6a38f6c --- /dev/null +++ b/profiles/migrations/0003_add_question_and_sub_question_to_answer.py @@ -0,0 +1,36 @@ +# Generated by Django 4.1.10 on 2023-09-21 10:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "profiles", + "0002_add_mandatory_number_of_sub_question_answers_to_question", + ), + ] + + operations = [ + migrations.AddField( + model_name="answer", + name="question", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="answers", + to="profiles.question", + ), + ), + migrations.AddField( + model_name="answer", + name="sub_question", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="answers", + to="profiles.subquestion", + ), + ), + ] diff --git a/profiles/migrations/0004_rename_number_of_choices_question_number_of_options_to_choose.py b/profiles/migrations/0004_rename_number_of_choices_question_number_of_options_to_choose.py new file mode 100644 index 0000000..ffd06f0 --- /dev/null +++ b/profiles/migrations/0004_rename_number_of_choices_question_number_of_options_to_choose.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.10 on 2023-09-22 05:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("profiles", "0003_add_question_and_sub_question_to_answer"), + ] + + operations = [ + migrations.RenameField( + model_name="question", + old_name="number_of_choices", + new_name="number_of_options_to_choose", + ), + ] diff --git a/profiles/migrations/0005_add_model_postalcode_and_postalcoderesult.py b/profiles/migrations/0005_add_model_postalcode_and_postalcoderesult.py new file mode 100644 index 0000000..d1511d4 --- /dev/null +++ b/profiles/migrations/0005_add_model_postalcode_and_postalcoderesult.py @@ -0,0 +1,89 @@ +# Generated by Django 4.1.10 on 2023-09-27 05:40 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "profiles", + "0004_rename_number_of_choices_question_number_of_options_to_choose", + ), + ] + + operations = [ + migrations.CreateModel( + name="PostalCode", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("postal_code", models.CharField(max_length=10, null=True)), + ], + ), + migrations.CreateModel( + name="PostalCodeResult", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "postal_code_type", + models.CharField( + choices=[("Home", "Home"), ("Work", "Work")], + default="Work", + max_length=4, + null=True, + ), + ), + ("count", models.PositiveIntegerField(default=0)), + ( + "postal_code", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="postal_code_results", + to="profiles.postalcode", + ), + ), + ( + "result", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="postal_code_results", + to="profiles.result", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="postalcoderesult", + constraint=models.CheckConstraint( + check=models.Q( + models.Q( + ("postal_code__isnull", True), + ("postal_code_type__isnull", True), + ), + models.Q( + ("postal_code__isnull", False), + ("postal_code_type__isnull", False), + ), + _connector="OR", + ), + name="postal_code_and_postal_code_type_must_be_jointly_null", + ), + ), + ] diff --git a/profiles/migrations/0006_add_model_postalcodetype.py b/profiles/migrations/0006_add_model_postalcodetype.py new file mode 100644 index 0000000..69973a8 --- /dev/null +++ b/profiles/migrations/0006_add_model_postalcodetype.py @@ -0,0 +1,35 @@ +# Generated by Django 4.1.10 on 2023-09-29 07:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("profiles", "0005_add_model_postalcode_and_postalcoderesult"), + ] + + operations = [ + migrations.CreateModel( + name="PostalCodeType", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "type_name", + models.CharField( + choices=[("Home", "Home"), ("Optional", "Optional")], + default="Optional", + max_length=8, + null=True, + ), + ), + ], + ), + ] diff --git a/profiles/migrations/0007_alter_postalcoderesult_postal_code_type.py b/profiles/migrations/0007_alter_postalcoderesult_postal_code_type.py new file mode 100644 index 0000000..f2d2543 --- /dev/null +++ b/profiles/migrations/0007_alter_postalcoderesult_postal_code_type.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.10 on 2023-09-29 07:38 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("profiles", "0006_add_model_postalcodetype"), + ] + + operations = [ + migrations.AlterField( + model_name="postalcoderesult", + name="postal_code_type", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="postal_code_results", + to="profiles.postalcodetype", + ), + ), + ] diff --git a/profiles/migrations/0008_alter_question_condition_related_names.py b/profiles/migrations/0008_alter_question_condition_related_names.py new file mode 100644 index 0000000..4f9df4f --- /dev/null +++ b/profiles/migrations/0008_alter_question_condition_related_names.py @@ -0,0 +1,50 @@ +# Generated by Django 4.1.10 on 2023-12-13 09:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("profiles", "0007_alter_postalcoderesult_postal_code_type"), + ] + + operations = [ + migrations.AlterField( + model_name="questioncondition", + name="option_conditions", + field=models.ManyToManyField( + related_name="question_conditions", to="profiles.option" + ), + ), + migrations.AlterField( + model_name="questioncondition", + name="question", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="question_conditions", + to="profiles.question", + ), + ), + migrations.AlterField( + model_name="questioncondition", + name="question_condition", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="condition_question_conditions", + to="profiles.question", + ), + ), + migrations.AlterField( + model_name="questioncondition", + name="sub_question_condition", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="question_conditions", + to="profiles.subquestion", + ), + ), + ] diff --git a/profiles/migrations/0009_subquestioncondition.py b/profiles/migrations/0009_subquestioncondition.py new file mode 100644 index 0000000..9189202 --- /dev/null +++ b/profiles/migrations/0009_subquestioncondition.py @@ -0,0 +1,45 @@ +# Generated by Django 4.1.10 on 2023-12-13 09:13 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("profiles", "0008_alter_question_condition_related_names"), + ] + + operations = [ + migrations.CreateModel( + name="SubQuestionCondition", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "option", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="sub_question_conditions", + to="profiles.option", + ), + ), + ( + "sub_question", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="sub_question_conditions", + to="profiles.subquestion", + ), + ), + ], + ), + ] diff --git a/profiles/migrations/__init__.py b/profiles/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/profiles/models.py b/profiles/models.py new file mode 100644 index 0000000..7cd651f --- /dev/null +++ b/profiles/models.py @@ -0,0 +1,201 @@ +from django.db import models + + +class Question(models.Model): + number = models.CharField(max_length=3, null=True) + question = models.CharField(max_length=255, null=True) + description = models.CharField(max_length=255, null=True) + number_of_options_to_choose = models.CharField(max_length=2, default="1") + mandatory_number_of_sub_questions_to_answer = models.CharField( + max_length=2, default="*" + ) + + class Meta: + ordering = ["number"] + + def __str__(self): + return f"question number:{self.number}, question: {self.question}" + + @property + def num_sub_questions(self): + return self.sub_questions.count() + + +class SubQuestion(models.Model): + description = models.CharField(max_length=255, null=True) + additional_description = models.CharField(max_length=255, null=True) + question = models.ForeignKey( + "Question", related_name="sub_questions", null=True, on_delete=models.CASCADE + ) + order_number = models.PositiveSmallIntegerField(null=True) + + class Meta: + ordering = ["question__number"] + + def __str__(self): + return f"{self.description}" + + +class Option(models.Model): + value = models.CharField(max_length=255, null=True) + affect_result = models.BooleanField(default=True) + question = models.ForeignKey( + "Question", related_name="options", on_delete=models.CASCADE, null=True + ) + sub_question = models.ForeignKey( + "SubQuestion", related_name="options", on_delete=models.CASCADE, null=True + ) + results = models.ManyToManyField("Result", related_name="options") + order_number = models.PositiveSmallIntegerField(null=True) + + class Meta: + ordering = ["question__number", "sub_question__question__number"] + + def __str__(self): + return f"Value: {self.value}" + + +class Result(models.Model): + topic = models.CharField(max_length=64, null=True) + value = models.CharField(max_length=64, null=True) + description = models.TextField(null=True) + + class Meta: + ordering = ["id"] + + def __str__(self): + return f"{self.topic} / {self.value}" + + +class Answer(models.Model): + user = models.ForeignKey( + "account.User", related_name="answers", on_delete=models.CASCADE + ) + option = models.ForeignKey( + "Option", related_name="answers", on_delete=models.CASCADE + ) + question = models.ForeignKey( + "Question", related_name="answers", null=True, on_delete=models.CASCADE + ) + sub_question = models.ForeignKey( + "SubQuestion", related_name="answers", null=True, on_delete=models.CASCADE + ) + + created = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user", "option"], name="unique_user_and_option" + ) + ] + ordering = ["id"] + + def __str__(self): + return f"{self.option.value}" + + +class QuestionCondition(models.Model): + question = models.ForeignKey( + "Question", + related_name="question_conditions", + null=True, + on_delete=models.CASCADE, + ) + + question_condition = models.ForeignKey( + "Question", + related_name="condition_question_conditions", + null=True, + on_delete=models.CASCADE, + ) + sub_question_condition = models.ForeignKey( + "SubQuestion", + related_name="question_conditions", + null=True, + on_delete=models.CASCADE, + ) + option_conditions = models.ManyToManyField( + "Option", related_name="question_conditions" + ) + + +class SubQuestionCondition(models.Model): + sub_question = models.ForeignKey( + "SubQuestion", + related_name="sub_question_conditions", + null=True, + on_delete=models.CASCADE, + ) + option = models.ForeignKey( + "Option", + null=True, + on_delete=models.CASCADE, + related_name="sub_question_conditions", + ) + + +class PostalCode(models.Model): + postal_code = models.CharField(max_length=10, null=True) + + def __str__(self): + return f"{self.postal_code}" + + +class PostalCodeType(models.Model): + HOME_POSTAL_CODE = "Home" + OPTIONAL_POSTAL_CODE = "Optional" + POSTAL_CODE_TYPE_CHOICES = [ + (HOME_POSTAL_CODE, HOME_POSTAL_CODE), + (OPTIONAL_POSTAL_CODE, OPTIONAL_POSTAL_CODE), + ] + type_name = models.CharField( + max_length=8, + null=True, + choices=POSTAL_CODE_TYPE_CHOICES, + default=OPTIONAL_POSTAL_CODE, + ) + + def __str__(self): + return f"{self.type_name}" + + +class PostalCodeResult(models.Model): + postal_code = models.ForeignKey( + "PostalCode", + null=True, + on_delete=models.CASCADE, + related_name="postal_code_results", + ) + + postal_code_type = models.ForeignKey( + "PostalCodeType", + null=True, + on_delete=models.CASCADE, + related_name="postal_code_results", + ) + result = models.ForeignKey( + "Result", related_name="postal_code_results", on_delete=models.CASCADE + ) + count = models.PositiveIntegerField(default=0) + + class Meta: + constraints = [ + models.CheckConstraint( + check=models.Q( + models.Q(postal_code__isnull=True) + & models.Q(postal_code_type__isnull=True) + ) + | models.Q( + models.Q(postal_code__isnull=False) + & models.Q(postal_code_type__isnull=False) + ), + name="postal_code_and_postal_code_type_must_be_jointly_null", + ) + ] + + def __str__(self): + if self.postal_code and self.postal_code_type: + return f"{self.postal_code_type} postal_code: {self.postal_code}, count: {self.count}" + else: + return f"count: {self.count}" diff --git a/profiles/signals.py b/profiles/signals.py new file mode 100644 index 0000000..a998c95 --- /dev/null +++ b/profiles/signals.py @@ -0,0 +1,13 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from profiles.models import Answer +from profiles.utils import get_user_result + + +@receiver(post_save, sender=Answer) +def answer_on_save(sender, **kwargs): + obj = kwargs["instance"] + user = obj.user + user.result = get_user_result(user) + user.save() diff --git a/profiles/tests/__init__.py b/profiles/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/profiles/tests/api/test_answer.py b/profiles/tests/api/test_answer.py new file mode 100644 index 0000000..2e1338e --- /dev/null +++ b/profiles/tests/api/test_answer.py @@ -0,0 +1,157 @@ +import pytest +from rest_framework.authtoken.models import Token +from rest_framework.reverse import reverse + +from account.models import User +from profiles.models import Answer, Option, Question, SubQuestion + + +def test_answer_post_unauthenticated(api_client): + url = reverse("profiles:answer-list") + response = api_client.post(url) + assert response.status_code == 401 + + +@pytest.mark.django_db +def test_poll_start(api_client): + User.objects.all().count() == 0 + url = reverse("profiles:question-start-poll") + response = api_client.post(url) + assert response.status_code == 200 + User.objects.all().count() == 1 + + +@pytest.mark.django_db +def test_post_answer(api_client_authenticated, users, questions, options): + user = users.get(username="test1") + assert Answer.objects.count() == 0 + answer_url = reverse("profiles:answer-list") + question1 = questions.get(number="1") + option = options.get(question=question1, value="no") + response = api_client_authenticated.post( + answer_url, {"option": option.id, "question": question1.id} + ) + assert response.status_code == 201 + assert Answer.objects.count() == 1 + user.refresh_from_db() + assert user.result.value == "negative result" + + +@pytest.mark.django_db +def test_post_answer_answer_is_updated( + api_client_authenticated, users, answers, questions, options +): + user = users.get(username="test1") + answer = answers.filter(user=user).first() + question = answer.question + option = answer.option + assert option.value == "no" + new_option = options.get(value="yes") + answer_url = reverse("profiles:answer-list") + api_client_authenticated.post( + answer_url, {"option": new_option.id, "question": question.id} + ) + answer.refresh_from_db() + assert answer.option.value == "yes" + + +@pytest.mark.django_db +def test_post_answer_to_sub_question( + api_client_authenticated, users, questions, sub_questions, options +): + user = users.get(username="test1") + answer_url = reverse("profiles:answer-list") + how_ofter_public_transport_question = Question.objects.get(number="2") + train_sub_q = SubQuestion.objects.get( + question=how_ofter_public_transport_question, description="train" + ) + option = Option.objects.get(sub_question=train_sub_q, value="daily") + response = api_client_authenticated.post( + answer_url, + { + "option": option.id, + "question": how_ofter_public_transport_question.id, + "sub_question": train_sub_q.id, + }, + ) + assert response.status_code == 201 + assert Answer.objects.count() == 1 + assert Answer.objects.filter(user=user).first().option == option + + +@pytest.mark.django_db +def test_post_answer_where_question_not_related_to_option( + api_client_authenticated, users, questions, sub_questions, options +): + answer_url = reverse("profiles:answer-list") + train_sub_q = sub_questions.get(description="train") + option = options.get(value="daily", sub_question=train_sub_q) + question1 = questions.get(number="1") + + response = api_client_authenticated.post( + answer_url, + { + "option": option.id, + "question": question1.id, + }, + ) + assert response.status_code == 404 + assert Answer.objects.count() == 0 + + +@pytest.mark.django_db +def test_answer_get_result(api_client_authenticated, users, answers): + url = reverse("profiles:answer-get-result") + response = api_client_authenticated.get(url) + assert response.status_code == 200 + assert response.json()["value"] == "negative result" + + +@pytest.mark.django_db +def test_post_answer_where_condition_not_met( + api_client, + users, + questions, + sub_questions, + question_conditions, + options, + results, + answers, +): + user = users.get(username="never train user") + token = Token.objects.create(user=user) + api_client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + question3 = questions.get(number="3") + option_easy = Option.objects.get(question=question3, value="easy") + + answer_url = reverse("profiles:answer-list") + response = api_client.post( + answer_url, {"option": option_easy.id, "question": question3.id} + ) + assert response.status_code == 405 + + +@pytest.mark.django_db +def test_post_answer_where_condition_is_met( + api_client, + users, + questions, + sub_questions, + question_conditions, + options, + results, + answers, +): + user = users.get(username="daily train user") + num_answers = answers.count() + token = Token.objects.create(user=user) + api_client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + question3 = questions.get(number="3") + option_easy = Option.objects.get(question=question3, value="easy") + + answer_url = reverse("profiles:answer-list") + response = api_client.post( + answer_url, {"option": option_easy.id, "question": question3.id} + ) + assert response.status_code == 201 + assert answers.count() == num_answers + 1 diff --git a/profiles/tests/api/test_postal_code_result.py b/profiles/tests/api/test_postal_code_result.py new file mode 100644 index 0000000..4d5b195 --- /dev/null +++ b/profiles/tests/api/test_postal_code_result.py @@ -0,0 +1,151 @@ +import pytest +from django.db.models import Sum +from rest_framework.reverse import reverse + +from account.models import User +from profiles.models import ( + Answer, + PostalCode, + PostalCodeResult, + PostalCodeType, + SubQuestion, +) + + +@pytest.mark.django_db +def test_postal_code_result(api_client, questions, sub_questions, options, results): + num_users = 21 + num_answers = 0 + start_poll_url = reverse("profiles:question-start-poll") + end_poll_url = reverse("profiles:question-end-poll") + answer_url = reverse("profiles:answer-list") + positive_result = results.get(value="positive result") + negative_result = results.get(value="negative result") + question1 = questions.get(number="1") + question2 = questions.get(number="2") + car_sub_q = sub_questions.get(question=question2, description="car") + car_sub_q_options = options.filter(sub_question=car_sub_q) + q1_options = options.filter(question=question1) + questions = {question1: q1_options, car_sub_q: car_sub_q_options} + postal_codes = [None, "20100", "20200", "20210", "20100"] + postal_code_types = [ + None, + PostalCodeType.HOME_POSTAL_CODE, + PostalCodeType.HOME_POSTAL_CODE, + PostalCodeType.OPTIONAL_POSTAL_CODE, + PostalCodeType.OPTIONAL_POSTAL_CODE, + ] + start_poll_url = reverse("profiles:question-start-poll") + for i in range(num_users): + response = api_client.post(start_poll_url) + assert response.status_code == 200 + + token = response.json()["token"] + user_id = response.json()["id"] + User.objects.all().count() == 1 + i + user = User.objects.get(id=user_id) + index = i % 5 + postal_code_location = postal_code_types[index] + if postal_code_location == PostalCodeType.HOME_POSTAL_CODE: + user.profile.postal_code = postal_codes[index] + elif postal_code_location == PostalCodeType.OPTIONAL_POSTAL_CODE: + user.profile.optional_postal_code = postal_codes[index] + user.profile.save() + # negative options(answer) has index 0, positive 1 in fixure opiton querysets + # negative are no/never and positive are yes/daily + # Make 2/3 of answers negative + if i % 3 < 2: + option_index = 0 + else: + option_index = 1 + for q_item in questions.items(): + body = {"option": q_item[1][option_index].id} + if isinstance(q_item[0], SubQuestion): + body["sub_question"] = q_item[0].id + body["question"] = q_item[0].question.id + else: + body["question"] = q_item[0].id + + api_client.credentials(HTTP_AUTHORIZATION=f"Token {token}") + response = api_client.post(answer_url, body) + num_answers += 1 + assert Answer.objects.count() == num_answers + + user = User.objects.get(id=user_id) + if option_index == 0: + assert user.result == negative_result + else: + assert user.result == positive_result + + response = api_client.post(end_poll_url) + assert response.status_code == 200 + user = User.objects.get(id=user_id) + assert user.postal_code_result_saved is True + api_client.credentials() + + # Note 20100 is both a Home and Optional postal code + assert PostalCode.objects.count() == 3 + # 2 * 5 number of different results * absolute(home and optional) number of postal codes + assert PostalCodeResult.objects.count() == 10 + # A count should be added for every user that answers and ends the poll + assert ( + PostalCodeResult.objects.aggregate(total_count=(Sum("count")))["total_count"] + == num_users + ) + num_positive_results = PostalCodeResult.objects.filter( + result=positive_result + ).aggregate(total_count=(Sum("count")))["total_count"] + num_negative_results = PostalCodeResult.objects.filter( + result=negative_result + ).aggregate(total_count=(Sum("count")))["total_count"] + # 1/3 of the results are negative + assert num_negative_results == pytest.approx(num_users * (1 / 3), 1) + # 2/3 are positive + assert num_positive_results == pytest.approx(num_users * (2 / 3), 1) + + url = reverse("profiles:postalcoderesult-list") + response = api_client.get(url) + assert response.status_code == 200 + assert response.json()["count"] == 10 + postal_code_20100 = PostalCode.objects.get(postal_code=20100) + url = ( + reverse("profiles:postalcoderesult-list") + + f"?postal_code={postal_code_20100.id}" + ) + response = api_client.get(url) + assert response.json()["count"] == 4 + postal_code_type_home = PostalCodeType.objects.get( + type_name=PostalCodeType.HOME_POSTAL_CODE + ) + url = ( + reverse("profiles:postalcoderesult-list") + + f"?postal_code_type={postal_code_type_home.id}" + ) + response = api_client.get(url) + assert response.json()["count"] == 4 + url = ( + reverse("profiles:postalcoderesult-list") + + f"?postal_code_type={postal_code_type_home.id}&postal_code={postal_code_20100.id}" + ) + response = api_client.get(url) + assert response.json()["count"] == 2 + url = reverse("profiles:postalcoderesult-list") + "?postal_code_type=" + response = api_client.get(url) + assert response.json()["count"] == 2 + url = reverse("profiles:postalcoderesult-list") + "?postal_code_type=&postal_code=" + response = api_client.get(url) + assert response.json()["count"] == 2 + + +@pytest.mark.django_db +def test_non_existing_postal_code_type(api_client): + url = reverse("profiles:postalcoderesult-list") + "?postal_code_type=42" + response = api_client.get(url) + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_non_existing_postal_code(api_client): + url = reverse("profiles:postalcoderesult-list") + "?postal_code=42" + response = api_client.get(url) + assert response.status_code == 404 diff --git a/profiles/tests/api/test_question.py b/profiles/tests/api/test_question.py new file mode 100644 index 0000000..83294d9 --- /dev/null +++ b/profiles/tests/api/test_question.py @@ -0,0 +1,421 @@ +import time + +import pytest +from django.conf import settings +from rest_framework.authtoken.models import Token +from rest_framework.reverse import reverse + +from profiles.models import Answer, PostalCodeResult + + +@pytest.mark.django_db +def test_questions_condition_states_not_authenticated( + api_client, users, questions, question_conditions +): + url = reverse("profiles:question-get-questions-conditions-states") + response = api_client.get(url) + assert response.status_code == 401 + + +@pytest.mark.django_db +def test_questions_condition_states( + api_client, users, answers, questions, question_conditions +): + user = users.get(username="car user") + token = Token.objects.create(user=user) + api_client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + url = reverse("profiles:question-get-questions-conditions-states") + response = api_client.get(url) + assert response.status_code == 200 + how_often_car_question = questions.get(number="1b") + why_use_train_question = questions.get(number="3") + + assert response.json()[0]["id"] == how_often_car_question.id + assert response.json()[0]["state"] is True + assert response.json()[1]["id"] == why_use_train_question.id + assert response.json()[1]["state"] is False + + user = users.get(username="non car user") + token = Token.objects.create(user=user) + api_client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + url = reverse("profiles:question-get-questions-conditions-states") + response = api_client.get(url) + assert response.status_code == 200 + assert response.json()[0]["id"] == how_often_car_question.id + assert response.json()[0]["state"] is False + assert response.json()[1]["id"] == why_use_train_question.id + assert response.json()[1]["state"] is False + + user = users.get(username="car and train user") + token = Token.objects.create(user=user) + api_client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + url = reverse("profiles:question-get-questions-conditions-states") + response = api_client.get(url) + assert response.status_code == 200 + assert response.json()[0]["id"] == how_often_car_question.id + assert response.json()[0]["state"] is True + assert response.json()[1]["id"] == why_use_train_question.id + assert response.json()[1]["state"] is True + + +@pytest.mark.django_db +def test_get_questions_with_conditions( + api_client, users, answers, questions, question_conditions +): + url = reverse("profiles:question-get-questions-with-conditions") + response = api_client.get(url) + json_data = response.json() + assert json_data["count"] == 2 + assert json_data["results"][0]["number"] == "1b" + assert json_data["results"][1]["number"] == "3" + + +@pytest.mark.django_db +def test_question_condition_is_met( + api_client, users, answers, questions, question_conditions +): + user = users.get(username="car user") + token = Token.objects.create(user=user) + condition_url = reverse("profiles:question-check-if-question-condition-met") + api_client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + response = api_client.post( + condition_url, {"question": questions.get(number="1b").id} + ) + assert response.status_code == 200 + assert response.json()["condition_met"] is True + + +@pytest.mark.django_db +def test_question_condition_not_met( + api_client, users, answers, questions, question_conditions +): + user = users.get(username="non car user") + token = Token.objects.create(user=user) + condition_url = reverse("profiles:question-check-if-question-condition-met") + api_client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + response = api_client.post( + condition_url, {"question": questions.get(number="1b").id} + ) + assert response.status_code == 200 + assert response.json()["condition_met"] is False + + +@pytest.mark.django_db +def test_question_with_sub_question_condition_is_met( + api_client, + users, + questions, + sub_questions, + question_conditions, + options, + results, + answers, +): + user = users.get(username="daily train user") + token = Token.objects.create(user=user) + condition_url = reverse("profiles:question-check-if-question-condition-met") + api_client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + response = api_client.post( + condition_url, {"question": questions.get(number="3").id} + ) + assert response.status_code == 200 + assert response.json()["condition_met"] is True + + +@pytest.mark.django_db +def test_question_with_sub_question_condition_not_met( + api_client, + users, + questions, + sub_questions, + question_conditions, + options, + results, + answers, +): + user = users.get(username="never train user") + token = Token.objects.create(user=user) + condition_url = reverse("profiles:question-check-if-question-condition-met") + api_client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + response = api_client.post( + condition_url, {"question": questions.get(number="3").id} + ) + assert response.status_code == 200 + assert response.json()["condition_met"] is False + + +@pytest.mark.django_db +def test_question_not_in_condition( + api_client_authenticated, questions, question_conditions +): + # 1b question is not in condition + in_condition_url = reverse("profiles:question-in-condition") + response = api_client_authenticated.post( + in_condition_url, {"question": questions.get(number="1b").id} + ) + assert response.status_code == 200 + assert response.json()["in_condition"] is False + + +@pytest.mark.django_db +def test_question_in_condition( + api_client_authenticated, questions, question_conditions +): + # question 1 is in condition + in_condition_url = reverse("profiles:question-in-condition") + response = api_client_authenticated.post( + in_condition_url, {"question": questions.get(number="1").id} + ) + assert response.status_code == 200 + assert response.json()["in_condition"] is True + + +@pytest.mark.django_db +def test_end_poll(api_client_authenticated, questions, options): + url = reverse("profiles:question-end-poll") + response = api_client_authenticated.post(url) + assert response.status_code == 200 + url = reverse("profiles:answer-list") + response = api_client_authenticated.post( + url, + { + "option": options.get(value="yes").id, + "question": questions.get(number="1").id, + }, + ) + assert response.status_code == 401 + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "ip_address", + [ + ("192.168.1.41"), + ], +) +def test_questions_anon_throttling(api_client_with_custom_ip_address): + count = 0 + url = reverse("profiles:question-list") + num_requests = int( + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["anon"].split("/")[0] + ) + while count < num_requests: + response = api_client_with_custom_ip_address.get(url) + assert response.status_code == 200 + count += 1 + time.sleep(2) + response = api_client_with_custom_ip_address.get(url) + assert response.status_code == 429 + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "ip_address", + [ + ("192.168.1.42"), + ], +) +def test_questions_user_throttling(api_client_with_custom_ip_address, users): + user = users.get(username="test1") + token = Token.objects.create(user=user) + + api_client_with_custom_ip_address.credentials( + HTTP_AUTHORIZATION="Token " + token.key + ) + count = 0 + url = reverse("profiles:question-list") + num_requests = int( + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user"].split("/")[0] + ) + while count < num_requests: + response = api_client_with_custom_ip_address.get(url) + assert response.status_code == 200 + count += 1 + time.sleep(2) + response = api_client_with_custom_ip_address.get(url) + assert response.status_code == 429 + + +@pytest.mark.django_db +def test_question_list(api_client, questions): + url = reverse("profiles:question-list") + response = api_client.get(url) + assert response.status_code == 200 + assert len(response.json()["results"]) == questions.count() + + +@pytest.mark.django_db +def test_questions(api_client, questions, question_conditions, options, results): + question = questions.first() + url = reverse("profiles:question-detail", args=[str(question.id)]) + response = api_client.get(url) + assert response.status_code == 200 + json_response = response.json() + assert json_response["question"] == question.question + assert len(json_response["options"]) == options.filter(question=question).count() + assert ( + json_response["options"][0]["value"] + == options.filter(question=question).first().value + ) + assert ( + len(json_response["options"][0]["results"]) + == question.options.first().results.count() + ) + assert ( + json_response["options"][0]["results"][0]["value"] + == results.filter(options__question=question).first().value + ) + + +@pytest.mark.django_db +def test_get_question_by_number(api_client, questions): + url = reverse("profiles:question-get-question") + "?number=2" + response = api_client.get(url) + assert response.status_code == 200 + assert response.json()["question"] == questions.filter(number=2).first().question + + +@pytest.mark.django_db +def test_get_non_existing_question_by_number(api_client, questions): + url = reverse("profiles:question-get-question") + "?number=2222" + response = api_client.get(url) + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_get_question_by_numbers(api_client, questions): + url = reverse("profiles:question-get-question-numbers") + response = api_client.get(url) + assert response.status_code == 200 + assert response.json()["results"][-1]["number"] == questions.last().number + + +@pytest.mark.django_db +def test_result_count_is_filled_for_fun_is_false( + api_client_authenticated, answers, users +): + user = users.get(username="test1") + assert user.profile.is_filled_for_fun is False + url = reverse("profiles:question-end-poll") + response = api_client_authenticated.post(url) + assert response.status_code == 200 + assert PostalCodeResult.objects.count() == 1 + + +@pytest.mark.django_db +def test_result_count_is_filled_for_fun_is_true( + api_client_authenticated, answers, users +): + user = users.get(username="test1") + url = reverse("profiles:question-end-poll") + user.profile.is_filled_for_fun = True + user.profile.save() + response = api_client_authenticated.post(url) + assert response.status_code == 200 + assert PostalCodeResult.objects.count() == 0 + + +@pytest.mark.django_db +def test_result_count_result_can_be_used_is_true( + api_client_authenticated, answers, users +): + user = users.get(username="test1") + assert user.profile.result_can_be_used is True + url = reverse("profiles:question-end-poll") + response = api_client_authenticated.post(url) + assert response.status_code == 200 + assert PostalCodeResult.objects.count() == 1 + + +@pytest.mark.django_db +def test_result_count_result_can_be_used_is_false( + api_client_authenticated, answers, users +): + user = users.get(username="test1") + user.profile.result_can_be_used = False + user.profile.save() + url = reverse("profiles:question-end-poll") + response = api_client_authenticated.post(url) + assert response.status_code == 200 + assert PostalCodeResult.objects.count() == 0 + + +@pytest.mark.django_db +def test_sub_question_condition( + api_client_authenticated, questions, sub_question_conditions, options, sub_questions +): + url = reverse("profiles:question-start-poll") + response = api_client_authenticated.post(url) + assert response.status_code == 200 + answer_url = reverse("profiles:answer-list") + question_condition = questions.get(question="Do you use car?") + driving_question = questions.get(question="Questions about car driving") + sub_question = sub_questions.get(description="Do you drive yourself?") + + option_yes = options.get(value="yes", question=question_condition) + option_yes_i_drive = options.get(value="yes I drive", sub_question=sub_question) + option_no = options.get(value="no", question=question_condition) + + response = api_client_authenticated.post( + answer_url, + { + "option": option_yes.id, + "question": question_condition.id, + }, + ) + assert response.status_code == 201 + + condition_url = reverse("profiles:question-check-if-sub-question-condition-met") + response = api_client_authenticated.post( + condition_url, + {"sub_question": sub_questions.get(description="Do you drive yourself?").id}, + ) + assert response.status_code == 200 + assert response.json()["condition_met"] is True + # Now the condition is met ,so answering the question should return code 201 + response = api_client_authenticated.post( + answer_url, + { + "option": option_yes_i_drive.id, + "question": driving_question.id, + "sub_question": sub_question.id, + }, + ) + assert response.status_code == 201 + + response = api_client_authenticated.post( + answer_url, + { + "option": option_no.id, + "question": question_condition.id, + }, + ) + assert response.status_code == 201 + assert Answer.objects.all().count() == 2 + response = api_client_authenticated.post( + condition_url, + {"sub_question": sub_questions.get(description="Do you drive yourself?").id}, + ) + assert response.status_code == 200 + assert response.json()["condition_met"] is False + + response = api_client_authenticated.post( + answer_url, + { + "option": option_no.id, + "question": question_condition.id, + }, + ) + assert response.status_code == 201 + assert Answer.objects.all().count() == 2 + # Now the condition is Not met, so answering the question should return code 405 + response = api_client_authenticated.post( + answer_url, + { + "option": option_yes_i_drive.id, + "question": driving_question.id, + "sub_question": sub_question.id, + }, + ) + assert response.status_code == 405 + assert Answer.objects.all().count() == 2 diff --git a/profiles/tests/conftest.py b/profiles/tests/conftest.py new file mode 100644 index 0000000..5df5389 --- /dev/null +++ b/profiles/tests/conftest.py @@ -0,0 +1,198 @@ +import pytest +from rest_framework.authtoken.models import Token +from rest_framework.test import APIClient + +from account.models import Profile, User +from profiles.models import ( + Answer, + Option, + Question, + QuestionCondition, + Result, + SubQuestion, + SubQuestionCondition, +) + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def api_client_authenticated(users): + user = users.get(username="test1") + token = Token.objects.create(user=user) + api_client = APIClient() + api_client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + return api_client + + +@pytest.fixture() +def api_client_with_custom_ip_address(ip_address): + return APIClient(REMOTE_ADDR=ip_address) + + +@pytest.mark.django_db +@pytest.fixture +def questions(): + Question.objects.create(question="Do you use car?", number="1") + Question.objects.create(question="How often do you use car?", number="1b") + Question.objects.create( + question="How often do you use following means of public transport?", number="2" + ) + Question.objects.create(question="Why do you use train?", number="3") + + Question.objects.create(question="Questions about car driving", number="4") + + return Question.objects.all() + + +@pytest.mark.django_db +@pytest.fixture() +def sub_questions(questions): + question = questions.get(number="2") + SubQuestion.objects.create(question=question, description="train", order_number=0) + SubQuestion.objects.create(question=question, description="car", order_number=1) + question = Question.objects.get(number="4") + SubQuestion.objects.create( + question=question, description="Do you drive yourself?", order_number=0 + ) + return SubQuestion.objects.all() + + +@pytest.mark.django_db +@pytest.fixture +def options(questions, sub_questions, results): + positive_result = results.get(value="positive result") + negative_result = results.get(value="negative result") + question1 = questions.get(number="1") + option_no = Option.objects.create(value="no", question=question1) + option_no.results.add(negative_result) + option_yes = Option.objects.create(value="yes", question=question1) + option_yes.results.add(positive_result) + + train_sub_q = sub_questions.get(description="train") + car_sub_q = sub_questions.get(description="car") + Option.objects.create(value="never", sub_question=train_sub_q) + Option.objects.create(value="daily", sub_question=train_sub_q) + option_never = Option.objects.create(value="never", sub_question=car_sub_q) + option_never.results.add(negative_result) + option_daily = Option.objects.create(value="daily", sub_question=car_sub_q) + option_daily.results.add(positive_result) + question3 = Question.objects.get(number="3") + Option.objects.create(value="fast", question=question3) + Option.objects.create(value="easy", question=question3) + Option.objects.create( + value="yes I drive", + sub_question=SubQuestion.objects.get(description="Do you drive yourself?"), + ) + return Option.objects.all() + + +@pytest.mark.django_db +@pytest.fixture +def results(): + Result.objects.create(topic="negative", value="negative result") + Result.objects.create(topic="positive", value="positive result") + return Result.objects.all() + + +@pytest.mark.django_db +@pytest.fixture +def question_conditions(questions, sub_questions, options, results): + car_question = questions.get(number="1") + how_often_car_question = questions.get(number="1b") + how_ofter_public_transport_question = questions.get(number="2") + why_use_train_question = questions.get(number="3") + train_sub_q = sub_questions.get(description="train") + # Set condition, if uses train daily. + cond = QuestionCondition.objects.create( + question=why_use_train_question, + question_condition=how_ofter_public_transport_question, + sub_question_condition=train_sub_q, + ) + cond.option_conditions.add( + Option.objects.get(sub_question=train_sub_q, value="daily") + ) + cond = QuestionCondition.objects.create( + question=how_often_car_question, question_condition=car_question + ) + cond.option_conditions.add(Option.objects.get(question=car_question, value="yes")) + return QuestionCondition.objects.all() + + +@pytest.mark.django_db +@pytest.fixture +def sub_question_conditions(questions, sub_questions, options): + sub_question = sub_questions.get(description="Do you drive yourself?") + question_condition = questions.get(question="Do you use car?") + option_yes = options.get(value="yes", question=question_condition) + SubQuestionCondition.objects.create(sub_question=sub_question, option=option_yes) + return SubQuestionCondition.objects.all() + + +@pytest.mark.django_db +@pytest.fixture +def users(): + user = User.objects.create(username="test1") + Profile.objects.create(user=user) + user = User.objects.create(username="car user") + Profile.objects.create(user=user) + user = User.objects.create(username="non car user") + Profile.objects.create(user=user) + user = User.objects.create(username="daily train user") + Profile.objects.create(user=user) + user = User.objects.create(username="never train user") + Profile.objects.create(user=user) + user = User.objects.create(username="car and train user") + Profile.objects.create(user=user) + return User.objects.all() + + +@pytest.mark.django_db +@pytest.fixture +def answers(users, questions, options, sub_questions): + Answer.objects.create( + user=users.get(username="test1"), + question=questions.get(number="1"), + option=options.get(value="no"), + ) + Answer.objects.create( + user=users.get(username="car user"), + question=questions.get(number="1"), + option=options.get(value="yes"), + ) + Answer.objects.create( + user=users.get(username="non car user"), + question=questions.get(number="1"), + option=options.get(value="no"), + ) + # Fixtures used when testing if sub question condition is met in question + train_sub_q = sub_questions.get(description="train") + option_daily_train = options.get(value="daily", sub_question=train_sub_q) + option_never_train = options.get(value="never", sub_question=train_sub_q) + question2 = questions.get(number="2") + Answer.objects.create( + user=users.get(username="daily train user"), + question=question2, + option=option_daily_train, + ) + Answer.objects.create( + user=users.get(username="never train user"), + question=question2, + option=option_never_train, + ) + + # Fixtures to test questions_condition_states + Answer.objects.create( + user=users.get(username="car and train user"), + question=questions.get(number="1"), + option=options.get(value="yes"), + ) + Answer.objects.create( + user=users.get(username="car and train user"), + question=question2, + option=option_daily_train, + ) + return Answer.objects.all() diff --git a/profiles/tests/test_import_questions.py b/profiles/tests/test_import_questions.py new file mode 100644 index 0000000..fb651ad --- /dev/null +++ b/profiles/tests/test_import_questions.py @@ -0,0 +1,137 @@ +from io import StringIO + +import pytest +from django.core.management import call_command + +from profiles.models import ( + Option, + Question, + QuestionCondition, + Result, + SubQuestion, + SubQuestionCondition, +) + + +def import_command(*args, **kwargs): + out = StringIO() + call_command( + "import_questions", + *args, + stdout=out, + stderr=StringIO(), + **kwargs, + ) + return out.getvalue() + + +@pytest.mark.django_db +def test_import_questions(): + import_command() + # Test result animals + results_qs = Result.objects.all() + assert results_qs.count() == 6 + autoilija = Result.objects.get(topic_fi="Autoilija") + assert results_qs[0].topic_fi == autoilija.topic_fi + assert results_qs[1].value_sv == "Rutinerad Räv" + assert results_qs[2].description_en[0:15] == "Travels by foot" + assert results_qs[3].topic_en == "Public transport passenger" + assert results_qs[4].value_fi == "Kokeileva Kauris" + assert results_qs[5].description_sv[-12:] == "använda bil." + + # Test questions + assert Question.objects.count() == 20 + # Test question without sub questions + question1b1 = Question.objects.get(number="1b1") + assert question1b1.question_fi == "Miksi et koskaan kulje autolla?" + assert question1b1.number_of_options_to_choose == "+" + # Test Sub questions + question1 = Question.objects.get(number="1") + assert question1.num_sub_questions == 8 + assert question1.number_of_options_to_choose == "1" + assert question1.description_en[0:2] == "If" + sub_q_qs = SubQuestion.objects.filter(question=question1) + assert sub_q_qs.count() == 8 + sq_auto = sub_q_qs.get(order_number=0) + assert sq_auto.description_en == "Car" + assert Option.objects.filter(sub_question=sq_auto).count() == 6 + sq_auto_4_5 = Option.objects.get(sub_question=sq_auto, order_number=4) + assert sq_auto_4_5.value == "4-5" + sq_auto_4_5_results = sq_auto_4_5.results.all() + assert sq_auto_4_5_results.count() == 2 + assert sq_auto_4_5_results[0] == autoilija + assert sq_auto_4_5_results[1].topic_en == "Habit traveler" + + sq_walk = sub_q_qs.get(order_number=6) + sq_walk.description_sv == "Gående" + assert Option.objects.filter(sub_question=sq_walk).count() == 6 + assert Option.objects.get(sub_question=sq_walk, order_number=2).value == "1" + question1d = Question.objects.get(number="1d") + assert question1d.num_sub_questions == 0 + assert Option.objects.filter(question=question1d).count() == 3 + assert Option.objects.get(question=question1d, order_number=1).value == "Joskus" + + question4 = Question.objects.get(number="4") + assert question4.mandatory_number_of_sub_questions_to_answer == "*" + assert SubQuestion.objects.filter(question=question4).count() == 6 + joukkoliikenne = SubQuestion.objects.get(question=question4, order_number=2) + assert ( + joukkoliikenne.additional_description + == "Tärkein syy, miksi käytän joukkoliikennettä on:" + ) + assert Option.objects.filter(sub_question=joukkoliikenne).count() == 12 + option_saa = Option.objects.get(sub_question=joukkoliikenne, order_number=3) + assert option_saa.value == "säätila (sade, tuuli, jne.)" + option_saa_results = option_saa.results.all() + assert option_saa_results.count() == 3 + assert option_saa_results[0].topic_fi == "Joukkoliikenteen käyttäjä" + assert option_saa_results[1].topic_sv == "MaaS-resenär" + assert option_saa_results[2].topic_en == "Conscious traveler" + + # Test SubQuestion conditions + sub_question_condition = SubQuestionCondition.objects.get( + sub_question=joukkoliikenne + ) + assert sub_question_condition.option == Option.objects.get(value_fi="Linja-autolla") + + question14 = Question.objects.get(number="14") + assert question14.options.count() == 2 + assert ( + question14.options.all().order_by("id")[1].value_en == "most comfortable route" + ) + # Test question condition + assert QuestionCondition.objects.all().count() == 13 + conditions = QuestionCondition.objects.filter(question=question14) + assert conditions.count() == 2 + sq_train = SubQuestion.objects.get(question=question1, order_number=2) + condition = conditions.get( + question_condition=question1, sub_question_condition=sq_train + ) + assert condition.option_conditions.count() == 5 + assert condition.option_conditions.all()[0].value == "<1" + assert condition.option_conditions.all()[4].value == "6-7" + # Test that rows are preserved and duplicates are not generated + import_command() + assert Result.objects.count() == 6 + assert Question.objects.count() == 20 + assert QuestionCondition.objects.all().count() == 13 + + assert question4.id == Question.objects.get(number="4").id + new_joukkoliikenne = SubQuestion.objects.get(question=question4, order_number=2) + assert joukkoliikenne.id == new_joukkoliikenne.id + new_option_saa = Option.objects.get(sub_question=new_joukkoliikenne, order_number=3) + new_option_saa_results = new_option_saa.results.all() + assert new_option_saa_results.count() == 3 + assert option_saa_results[0].id == new_option_saa_results[0].id + assert option_saa_results[1].id == new_option_saa_results[1].id + assert option_saa_results[2].id == new_option_saa_results[2].id + assert question14.id == Question.objects.get(number="14").id + assert autoilija.id == Result.objects.get(topic_fi="Autoilija").id + question8 = Question.objects.get(number="8") + # Test that rows with info of Category 2 is skipped + assert Option.objects.filter(question=question8).count() == 5 + condition = QuestionCondition.objects.get(question=question8) + assert condition.question_condition == Question.objects.get(number="7") + assert condition.option_conditions.all()[0].value_fi == "Ei" + question10 = Question.objects.get(number="10") + assert question10.options.count() == 5 diff --git a/profiles/tests/test_postal_code_result_model.py b/profiles/tests/test_postal_code_result_model.py new file mode 100644 index 0000000..25d7494 --- /dev/null +++ b/profiles/tests/test_postal_code_result_model.py @@ -0,0 +1,47 @@ +import pytest +from django.db.utils import IntegrityError + +from profiles.models import PostalCode, PostalCodeResult, PostalCodeType, Result + + +@pytest.mark.django_db +def test_postal_code_result_create_no_exception_raised(results): + postal_code = None + postal_code_type = None + result = Result.objects.first() + # Raises no exception both postal_code and postal_code_type is null + try: + PostalCodeResult.objects.create( + result=result, + postal_code=postal_code, + postal_code_type=postal_code_type, + ) + except IntegrityError: + assert False + + postal_code = PostalCode.objects.create(postal_code="20210") + postal_code_type = PostalCodeType.objects.create(type_name="work") + # Raises no exception both postal_code and postal_code_type is null + try: + PostalCodeResult.objects.create( + result=result, + postal_code=postal_code, + postal_code_type=postal_code_type, + ) + except IntegrityError: + assert False + + +@pytest.mark.django_db +def test_postal_code_reslut_create_exception_raised(results): + result = Result.objects.first() + postal_code_type = None + # As both postal_code an postal_code_type must be jointly null, raises exception + postal_code = PostalCode.objects.create(postal_code="20210") + with pytest.raises(IntegrityError) as excinfo: + PostalCodeResult.objects.create( + result=result, + postal_code=postal_code, + postal_code_type=postal_code_type, + ) + assert PostalCodeResult._meta.constraints[0].name in str(excinfo) diff --git a/profiles/translation.py b/profiles/translation.py new file mode 100644 index 0000000..711cff0 --- /dev/null +++ b/profiles/translation.py @@ -0,0 +1,37 @@ +from modeltranslation.translator import TranslationOptions, translator + +from profiles.models import Option, Question, Result, SubQuestion + + +class QuestionTranslationOptions(TranslationOptions): + fields = ( + "question", + "description", + ) + + +translator.register(Question, QuestionTranslationOptions) + + +class SubQuestionTranslationOptions(TranslationOptions): + fields = ( + "description", + "additional_description", + ) + + +translator.register(SubQuestion, SubQuestionTranslationOptions) + + +class OptionTranslationOptions(TranslationOptions): + fields = ("value",) + + +translator.register(Option, OptionTranslationOptions) + + +class ResultTranslationOptions(TranslationOptions): + fields = ("topic", "value", "description") + + +translator.register(Result, ResultTranslationOptions) diff --git a/profiles/utils.py b/profiles/utils.py new file mode 100644 index 0000000..85add5e --- /dev/null +++ b/profiles/utils.py @@ -0,0 +1,36 @@ +import secrets +import string + +from account.models import User +from profiles.models import Answer, Result + + +def get_user_result(user: User) -> Result: + answer_qs = Answer.objects.filter(user=user) + if answer_qs.count() == 0: + return None + + results = {r: 0 for r in Result.objects.all()} + for answer in answer_qs: + for result in answer.option.results.all(): + results[result] += 1 + result = max(results, key=results.get) + return result + + +def generate_password() -> str: + """ + https://docs.python.org/3/library/secrets.html#recipes-and-best-practices + Generate a 10 alphanumeric password with at least one lowercase character, + at least one uppercase character, and at least three digits: + """ + alphabet = string.ascii_letters + string.digits + while True: + password = "".join(secrets.choice(alphabet) for i in range(10)) + if ( + any(c.islower() for c in password) + and any(c.isupper() for c in password) + and sum(c.isdigit() for c in password) >= 3 + ): + break + return password diff --git a/profiles/views.py b/profiles/views.py new file mode 100644 index 0000000..71cf949 --- /dev/null +++ b/profiles/views.py @@ -0,0 +1,6 @@ +from django.http import JsonResponse +from django.middleware.csrf import get_token + + +def get_csrf(request): + return JsonResponse({"csrfToken": get_token(request)}) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..e8dcbdb --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +DJANGO_SETTINGS_MODULE = mpbackend.settings \ No newline at end of file diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..8bcce90 --- /dev/null +++ b/requirements.in @@ -0,0 +1,17 @@ +django +djangorestframework +django-modeltranslation +django-environ +django-extensions +pytest-django +pytest-cov +pip-tools +black +isort +flake8 +pandas +psycopg2-binary +openpyxl +drf-spectacular +django-cors-headers +freezegun diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bcf340f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,131 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile requirements.in +# +asgiref==3.6.0 + # via django +attrs==23.1.0 + # via jsonschema +black==23.3.0 + # via -r requirements.in +build==0.10.0 + # via pip-tools +click==8.1.3 + # via + # black + # pip-tools +coverage[toml]==7.2.3 + # via pytest-cov +django==4.2 + # via + # -r requirements.in + # django-cors-headers + # django-extensions + # django-modeltranslation + # djangorestframework + # drf-spectacular +django-cors-headers==4.2.0 + # via -r requirements.in +django-environ==0.10.0 + # via -r requirements.in +django-extensions==3.2.1 + # via -r requirements.in +django-modeltranslation==0.18.9 + # via -r requirements.in +djangorestframework==3.14.0 + # via + # -r requirements.in + # drf-spectacular +drf-spectacular==0.26.2 + # via -r requirements.in +et-xmlfile==1.1.0 + # via openpyxl +exceptiongroup==1.1.1 + # via pytest +flake8==6.0.0 + # via -r requirements.in +freezegun==1.4.0 + # via -r requirements.in +inflection==0.5.1 + # via drf-spectacular +iniconfig==2.0.0 + # via pytest +isort==5.12.0 + # via -r requirements.in +jsonschema==4.17.3 + # via drf-spectacular +mccabe==0.7.0 + # via flake8 +mypy-extensions==1.0.0 + # via black +numpy==1.24.2 + # via pandas +openpyxl==3.1.2 + # via -r requirements.in +packaging==23.1 + # via + # black + # build + # pytest +pandas==2.0.0 + # via -r requirements.in +pathspec==0.11.1 + # via black +pip-tools==6.13.0 + # via -r requirements.in +platformdirs==3.2.0 + # via black +pluggy==1.0.0 + # via pytest +psycopg2-binary==2.9.6 + # via -r requirements.in +pycodestyle==2.10.0 + # via flake8 +pyflakes==3.0.1 + # via flake8 +pyproject-hooks==1.0.0 + # via build +pyrsistent==0.19.3 + # via jsonschema +pytest==7.3.1 + # via + # pytest-cov + # pytest-django +pytest-cov==4.0.0 + # via -r requirements.in +pytest-django==4.5.2 + # via -r requirements.in +python-dateutil==2.8.2 + # via + # freezegun + # pandas +pytz==2023.3 + # via + # djangorestframework + # pandas +pyyaml==6.0 + # via drf-spectacular +six==1.16.0 + # via python-dateutil +sqlparse==0.4.3 + # via django +tomli==2.0.1 + # via + # black + # build + # coverage + # pytest +typing-extensions==4.5.0 + # via django-modeltranslation +tzdata==2023.3 + # via pandas +uritemplate==4.1.1 + # via drf-spectacular +wheel==0.40.0 + # via pip-tools + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100644 index 0000000..5f2ecd7 --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,21 @@ +# NOTE, this script is added to the crontab as: 1 0 * * * /home/azureuser/mpbackend/scripts/backup.sh + +#!/bin/bash +BACKUP_PATH=/home/azureuser/backups/ +current_date=$(date +"%Y-%m-%d") +echo "Backuping mobilityprofile database on curren date $current_date..." +docker exec -i mpbackend_postgres_1 /usr/bin/pg_dump -U mobilityprofile -F t mobilityprofile | gzip -9 > ${BACKUP_PATH}mpbackend_backup_${current_date}.tar.gz +echo "Backup finished." + +: 'To restore: +To inspect the container: +docker inspect mpbackend_postgres_1 + +1. Go to the directory defined in constant $BACKUP_PATH, where the backups are located +2. Unzip the .gz file, e.g.: gunzip mpbackend_backup_YYYY-MM-DD.tar.gz +2. copy the .tar file to the container: e.g.: docker cp mpbackend_backup_YYYY-MM-DD.tar mpbackend_postgres_1:/tmp +3. Restore the database: docker exec mpbackend_postgres_1 pg_restore -U mobilityprofile -c -d mobilityprofile /tmp/mpbackend_backup_YYYY-MM-DD.tar +4. Optionally, remove the backup file from the container: docker exec mpbackend_postgres_1 rm /tmp/mpbackend_backup_YYYY-MM-DD.tar + +# For additinol informaiton, e.g.: https://simplebackups.com/blog/docker-postgres-backup-restore-guide-with-examples/#back-up-a-docker-postgresql-database +' diff --git a/scripts/update_and_start_prod_containers.sh b/scripts/update_and_start_prod_containers.sh new file mode 100755 index 0000000..0ec5ced --- /dev/null +++ b/scripts/update_and_start_prod_containers.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# Add git checkout +docker stop $(docker ps -aq) +docker rm $(docker ps -aq) +docker rmi $(docker images -aq) +docker system prune -a -f +# Remove all volumes, Except for mpbackend_postrgres-data as it contains the database +docker volume rm mpbackend_static +docker volume rm mpbackend_mpbackend + +docker-compose -f ../docker-compose.yml -f ../docker-compose.prod.yml up +docker-compose -f ../docker-compose.yml -f ../docker-compose.prod.yml start diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..6bf9768 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,34 @@ +[pep8] +max-line-length = 120 +exclude = *migrations* + +[flake8] +exclude = .git, + *migrations*, + venv, +max-line-length = 120 +ignore = E203,W503,N813 + +[tool:pytest] +DJANGO_SETTINGS_MODULE=mpbackend.settings +python_files = tests.py test_*.py *_tests.py + +[coverage:run] +branch = True +omit = *migrations*,*site-packages*,*venv* + +[isort] +atomic = true +combine_as_imports = true +indent = 4 +length_sort = false +multi_line_output = 3 +order_by_type = false +skip = venv +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = True +line_length = 88 + +[pydocstyle] +ignore = D100,D104,D105,D200,D203,D400 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..cf88aea --- /dev/null +++ b/setup.py @@ -0,0 +1,15 @@ +from setuptools import find_packages, setup + +setup( + name="mpbackend", + version="0.0.1", + license="AGPLv3", + packages=find_packages(), + include_package_data=True, + install_requires=[ + p + for p in open("requirements.txt", "rt").readlines() + if p and not p.startswith("#") + ], + zip_safe=False, +)