diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 3a7a5f0..81264af 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -12,10 +12,10 @@ jobs: env: # Database for tests - DATABASE_URL: postgis://postgres:postgres@localhost/smbackend + DATABASE_URL: postgres://postgres:postgres@localhost/mobilityprofile GOOGLE_RECAPTCHA_SECRET_KEY: testSecret - GOOGLE_RECAPTCHA_VERIFY_URL: https://www.google.com/recaptcha/api/siteverify - + GOOGLE_RECAPTCHA_VERIFY_URL: https://www.google.com/recaptcha/api/siteverif + steps: - uses: actions/checkout@v4 - name: Set up Python diff --git a/README.md b/README.md index e1f664e..4368131 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,8 @@ 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 +createdb -O mobilityprofile -l fi_FI.UTF-8 -E utf8 mobilityprofile ``` 5. Create database tables. diff --git a/account/migrations/0011_profile_gender.py b/account/migrations/0011_profile_gender.py index 8f1dbdb..e5a8c6b 100644 --- a/account/migrations/0011_profile_gender.py +++ b/account/migrations/0011_profile_gender.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("account", "0010_rename_age_profile_year_of_birth"), ] diff --git a/config_dev.env.example b/config_dev.env.example index 2f029bc..3454ad6 100644 --- a/config_dev.env.example +++ b/config_dev.env.example @@ -4,14 +4,14 @@ DEBUG=True # Configures database for mpbackend using URL style. The format is # -# postgis://USER:PASSWORD@HOST:PORT/NAME +# postgres://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 +DATABASE_URL=postgres://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 @@ -27,12 +27,6 @@ CORS_ORIGIN_WHITELIST=http://localhost:8080 # 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 diff --git a/docker-compose.yml b/docker-compose.yml index cbde345..f4dfa4c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,9 +11,13 @@ services: - "5432:5432" volumes: - postgres-data:/var/lib/postgresql/data - + memcached: image: memcached:latest + command: ["-I", "4m"] + ports: + - "11211:11211" + restart: always mpbackend: image: mpbackend diff --git a/docker/postgres/Dockerfile b/docker/postgres/Dockerfile index adf8e29..f104faa 100644 --- a/docker/postgres/Dockerfile +++ b/docker/postgres/Dockerfile @@ -1,8 +1,5 @@ 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 diff --git a/media/questions.xlsx b/media/questions.xlsx index 4c97b25..b410561 100644 Binary files a/media/questions.xlsx and b/media/questions.xlsx differ diff --git a/mpbackend/settings.py b/mpbackend/settings.py index ae5d700..ce24c01 100644 --- a/mpbackend/settings.py +++ b/mpbackend/settings.py @@ -15,13 +15,8 @@ env = environ.Env( DEBUG=(bool, False), LANGUAGES=(list, ["fi", "sv", "en"]), - DATABASE_URL=(str, "postgis:///servicemap"), + DATABASE_URL=(str, "postgres://mobilityprofile:mobilityprofile"), 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/"), @@ -41,7 +36,6 @@ DEBUG = env("DEBUG") ALLOWED_HOSTS = env("ALLOWED_HOSTS") - # Custom user model AUTH_USER_MODEL = "account.User" @@ -74,7 +68,6 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - # "mpbackend.middleware.TimingMiddleware", ] ROOT_URLCONF = "mpbackend.urls" @@ -241,12 +234,6 @@ }, } -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", diff --git a/profiles/api/views.py b/profiles/api/views.py index ae17f1a..c84da83 100644 --- a/profiles/api/views.py +++ b/profiles/api/views.py @@ -85,12 +85,9 @@ def get_or_create_row(model, filter): 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 + return Answer.objects.filter( + user=user, option=sub_question_condition.option + ).exists() def verify_recaptcha(token): @@ -123,7 +120,6 @@ def question_condition_met(question_condition_qs, user): 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 @@ -371,7 +367,7 @@ def check_if_question_condition_met(self, request): ) # 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: + if not question_condition_qs.exists(): return Response( f"QuestionCondition not found for question number {question_id}", status=status.HTTP_404_NOT_FOUND, @@ -457,7 +453,7 @@ def in_condition(self, request, *args, **kwargs): ) qs = QuestionCondition.objects.filter(question_condition=question) - if qs.count() > 0: + if qs.exists(): response_data["in_condition"] = True serializer = InConditionResponseSerializer(data=response_data) if serializer.is_valid(): @@ -611,7 +607,7 @@ def create(self, request, *args, **kwargs): status=status.HTTP_404_NOT_FOUND, ) question_condition_qs = QuestionCondition.objects.filter(question=question) - if question_condition_qs.count() > 0: + if question_condition_qs.exists(): 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", @@ -638,7 +634,7 @@ def create(self, request, *args, **kwargs): ) filter["other"] = other queryset = Answer.objects.filter(**filter) - if queryset.count() == 0: + if not queryset.exists(): filter["option"] = option Answer.objects.create(**filter) else: diff --git a/profiles/management/commands/import_questions.py b/profiles/management/commands/import_questions.py index de600f9..e40b82b 100644 --- a/profiles/management/commands/import_questions.py +++ b/profiles/management/commands/import_questions.py @@ -93,12 +93,15 @@ def get_and_create_results(data: pd.DataFrame) -> list: col_data = data[column] topic = get_language_dict(data.columns[RESULT_COLUMNS[0] + i]) description = get_language_dict(col_data[0]) + value = get_language_dict(col_data[1]) filter = {"topic": topic["fi"]} update_filter = {} for lang in LANGUAGES: update_filter[f"topic_{lang}"] = topic[lang] update_filter[f"description_{lang}"] = description[lang] + update_filter[f"value_{lang}"] = value[lang] + queryset = Result.objects.filter(**filter) if queryset.count() == 0: result = Result.objects.create(**filter) diff --git a/profiles/migrations/0016_result_value_result_value_en_result_value_fi_and_more.py b/profiles/migrations/0016_result_value_result_value_en_result_value_fi_and_more.py new file mode 100644 index 0000000..ca170e1 --- /dev/null +++ b/profiles/migrations/0016_result_value_result_value_en_result_value_fi_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2 on 2024-02-27 09:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("profiles", "0015_alter_question_options"), + ] + + operations = [ + migrations.AddField( + model_name="result", + name="value", + field=models.CharField(max_length=64, null=True), + ), + migrations.AddField( + model_name="result", + name="value_en", + field=models.CharField(max_length=64, null=True), + ), + migrations.AddField( + model_name="result", + name="value_fi", + field=models.CharField(max_length=64, null=True), + ), + migrations.AddField( + model_name="result", + name="value_sv", + field=models.CharField(max_length=64, null=True), + ), + ] diff --git a/profiles/migrations/0017_answer_create_indexes.py b/profiles/migrations/0017_answer_create_indexes.py new file mode 100644 index 0000000..2848d72 --- /dev/null +++ b/profiles/migrations/0017_answer_create_indexes.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2 on 2024-02-29 08:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("profiles", "0016_result_value_result_value_en_result_value_fi_and_more"), + ] + + operations = [ + migrations.AddIndex( + model_name="answer", + index=models.Index( + fields=["user", "question", "sub_question"], + name="profiles_an_user_id_8141b8_idx", + ), + ), + ] diff --git a/profiles/migrations/0018_postalcoderesult_add_ordering.py b/profiles/migrations/0018_postalcoderesult_add_ordering.py new file mode 100644 index 0000000..2749138 --- /dev/null +++ b/profiles/migrations/0018_postalcoderesult_add_ordering.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2 on 2024-02-29 08:56 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("profiles", "0017_answer_create_indexes"), + ] + + operations = [ + migrations.AlterModelOptions( + name="postalcoderesult", + options={"ordering": ["postal_code", "postal_code_type"]}, + ), + ] diff --git a/profiles/migrations/0019_questioncondition_subquestion_condition_add_ordering.py b/profiles/migrations/0019_questioncondition_subquestion_condition_add_ordering.py new file mode 100644 index 0000000..f64fda2 --- /dev/null +++ b/profiles/migrations/0019_questioncondition_subquestion_condition_add_ordering.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2 on 2024-02-29 10:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("profiles", "0018_postalcoderesult_add_ordering"), + ] + + operations = [ + migrations.AlterModelOptions( + name="questioncondition", + options={"ordering": ["id"]}, + ), + migrations.AlterModelOptions( + name="subquestioncondition", + options={"ordering": ["id"]}, + ), + ] diff --git a/profiles/migrations/0020_postalcode_postalcodetype_add_ordering.py b/profiles/migrations/0020_postalcode_postalcodetype_add_ordering.py new file mode 100644 index 0000000..1fa7e09 --- /dev/null +++ b/profiles/migrations/0020_postalcode_postalcodetype_add_ordering.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2 on 2024-02-29 10:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("profiles", "0019_questioncondition_subquestion_condition_add_ordering"), + ] + + operations = [ + migrations.AlterModelOptions( + name="postalcode", + options={"ordering": ["id"]}, + ), + migrations.AlterModelOptions( + name="postalcodetype", + options={"ordering": ["id"]}, + ), + ] diff --git a/profiles/models.py b/profiles/models.py index 7d645dc..8c26a0d 100644 --- a/profiles/models.py +++ b/profiles/models.py @@ -59,17 +59,18 @@ def __str__(self): class Result(models.Model): topic = models.CharField(max_length=64, null=True) description = models.TextField(null=True) + value = models.CharField(max_length=64, null=True) class Meta: ordering = ["id"] def __str__(self): - return f"{self.topic}" + return f"{self.topic} / {self.value}" class Answer(models.Model): user = models.ForeignKey( - "account.User", related_name="answers", on_delete=models.CASCADE + "account.User", related_name="answers", on_delete=models.CASCADE, db_index=True ) option = models.ForeignKey( "Option", related_name="answers", null=True, on_delete=models.CASCADE @@ -80,13 +81,17 @@ class Answer(models.Model): "Question", related_name="answers", null=True, on_delete=models.CASCADE ) sub_question = models.ForeignKey( - "SubQuestion", related_name="answers", null=True, on_delete=models.CASCADE + "SubQuestion", + related_name="answers", + null=True, + on_delete=models.CASCADE, ) created = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["id"] + indexes = [models.Index(fields=["user", "question", "sub_question"])] class AnswerOther(Answer): @@ -96,6 +101,9 @@ class Meta: class QuestionCondition(models.Model): + class Meta: + ordering = ["id"] + question = models.ForeignKey( "Question", related_name="question_conditions", @@ -121,6 +129,9 @@ class QuestionCondition(models.Model): class SubQuestionCondition(models.Model): + class Meta: + ordering = ["id"] + sub_question = models.ForeignKey( "SubQuestion", related_name="sub_question_conditions", @@ -136,6 +147,9 @@ class SubQuestionCondition(models.Model): class PostalCode(models.Model): + class Meta: + ordering = ["id"] + postal_code = models.CharField(max_length=10, null=True) def __str__(self): @@ -143,6 +157,9 @@ def __str__(self): class PostalCodeType(models.Model): + class Meta: + ordering = ["id"] + HOME_POSTAL_CODE = "Home" OPTIONAL_POSTAL_CODE = "Optional" POSTAL_CODE_TYPE_CHOICES = [ @@ -180,6 +197,7 @@ class PostalCodeResult(models.Model): count = models.PositiveIntegerField(default=0) class Meta: + ordering = ["postal_code", "postal_code_type"] constraints = [ models.CheckConstraint( check=models.Q( diff --git a/profiles/tests/api/test_postal_code.py b/profiles/tests/api/test_postal_code.py new file mode 100644 index 0000000..29a3c88 --- /dev/null +++ b/profiles/tests/api/test_postal_code.py @@ -0,0 +1,12 @@ +import pytest +from rest_framework.reverse import reverse + + +@pytest.mark.django_db +def test_list_postal_code(api_client, postal_codes): + response = api_client.get(reverse("profiles:postalcode-list")) + assert response.status_code == 200 + json_data = response.json() + assert json_data["count"] == postal_codes.count() + assert json_data["results"][0]["id"] == postal_codes.first().id + assert json_data["results"][0]["postal_code"] == postal_codes.first().postal_code diff --git a/profiles/tests/api/test_postal_code_type.py b/profiles/tests/api/test_postal_code_type.py new file mode 100644 index 0000000..f8e6867 --- /dev/null +++ b/profiles/tests/api/test_postal_code_type.py @@ -0,0 +1,12 @@ +import pytest +from rest_framework.reverse import reverse + + +@pytest.mark.django_db +def test_list_postal_code(api_client, postal_code_types): + response = api_client.get(reverse("profiles:postalcodetype-list")) + assert response.status_code == 200 + json_data = response.json() + assert json_data["count"] == postal_code_types.count() + assert json_data["results"][0]["id"] == postal_code_types.first().id + assert json_data["results"][0]["type_name"] == postal_code_types.first().type_name diff --git a/profiles/tests/conftest.py b/profiles/tests/conftest.py index 0c7f5b8..ef76e52 100644 --- a/profiles/tests/conftest.py +++ b/profiles/tests/conftest.py @@ -6,6 +6,8 @@ from profiles.models import ( Answer, Option, + PostalCode, + PostalCodeType, Question, QuestionCondition, Result, @@ -47,6 +49,22 @@ def api_client_with_custom_ip_address(ip_address): return APIClient(REMOTE_ADDR=ip_address) +@pytest.mark.django_db +@pytest.fixture +def postal_codes(): + PostalCode.objects.create(postal_code="20210") + PostalCode.objects.create(postal_code="20220") + return PostalCode.objects.all() + + +@pytest.mark.django_db +@pytest.fixture +def postal_code_types(): + PostalCodeType.objects.create(type_name=PostalCodeType.HOME_POSTAL_CODE) + PostalCodeType.objects.create(type_name=PostalCodeType.OPTIONAL_POSTAL_CODE) + return PostalCodeType.objects.all() + + @pytest.mark.django_db @pytest.fixture def questions_test_result(): diff --git a/profiles/tests/test_import_questions.py b/profiles/tests/test_import_questions.py index d9d358e..380c769 100644 --- a/profiles/tests/test_import_questions.py +++ b/profiles/tests/test_import_questions.py @@ -34,11 +34,14 @@ def test_import_questions(): assert results_qs.count() == 6 autoilija = Result.objects.get(topic_fi="Autoilija") assert results_qs[0].topic_fi == autoilija.topic_fi + assert "Enkel" in results_qs[0].value_sv assert "You know where" in results_qs[1].description_en assert "FotgÃĪngare" in results_qs[2].topic_sv assert results_qs[3].topic_en == "Public transport passenger" assert "Maas" in results_qs[4].topic_fi - + assert "Kauris" in results_qs[4].value_fi + assert "Hjort" in results_qs[4].value_sv + assert "Deer" in results_qs[4].value_en # Test questions assert Question.objects.count() == 17 # Test question without sub questions diff --git a/profiles/translation.py b/profiles/translation.py index f1983aa..bb504f7 100644 --- a/profiles/translation.py +++ b/profiles/translation.py @@ -31,7 +31,7 @@ class OptionTranslationOptions(TranslationOptions): class ResultTranslationOptions(TranslationOptions): - fields = ("topic", "description") + fields = ("topic", "description", "value") translator.register(Result, ResultTranslationOptions)