diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 41cd8d4..3f7d9b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,7 @@ such as bug reports, ideas, design, testing, and code. - [Migrations](#migrations) - [Create superuser](#create-superuser) - [Run the server](#run-the-server) + - [Privacy and Data Protection Guidelines](#privacy-and-data-protection-guidelines) ## Community Discussions @@ -137,3 +138,29 @@ When all migrations are applied and you have a superuser, run the server as foll ```sh python manage.py runserver ``` + +## Privacy and Data Protection Guidelines + +As an open-source community committed to upholding the highest standards of privacy and data security, we align our practices with principles derived from the General Data Protection Regulation (GDPR) and other similar privacy frameworks. While some GDPR principles are more relevant at an organizational level, many can be directly applied to software development, especially in features involving user data. Below are key guidelines that contributors should follow: + +1. **Data Minimization:** Only collect data that is essential for the intended functionality. Avoid unnecessary collection of personal information. When in doubt, less is more. + +2. **Consent and Transparency:** Ensure that the software provides clear mechanisms for obtaining user consent where applicable. Users should be informed about what data is collected, why it is collected, and how it will be used. + +3. **Anonymization and Pseudonymization:** Where possible, anonymize or pseudonymize personal data to reduce privacy risks. This is particularly crucial in datasets that may be publicly released or shared. + +4. **Security by Design:** Integrate data protection features from the earliest stages of development. This includes implementing robust encryption, access controls, and secure data storage practices. + +5. **Access Control:** Limit access to personal data to only those components or personnel who strictly need it for processing. Implement appropriate authentication and authorization mechanisms. + +6. **Data Portability:** Facilitate easy extraction and transfer of data in a common format, allowing users to move their data between different services seamlessly. + +7. **User Rights:** Respect user rights such as the right to access their data, the right to rectify inaccuracies, and the right to erasure (‘right to be forgotten’). + +8. **Regular Audits and Updates:** Regularly review and update the software to address emerging security vulnerabilities and ensure compliance with evolving data protection laws. + +9. **Documentation and Compliance:** Document data flows and privacy measures. While the software itself may not be directly subject to GDPR, good documentation practices help downstream users to achieve compliance. + +10. **Community Awareness:** Encourage a culture of privacy awareness and compliance within the community. Contributors should stay informed about data protection best practices and legal requirements. + +Remember, adhering to these guidelines not only helps in compliance with regulations like the GDPR but also builds trust with users and the broader community. As contributors, your commitment to these principles is invaluable in fostering a responsible and privacy-conscious software ecosystem. diff --git a/accounts/factories.py b/accounts/factories.py new file mode 100644 index 0000000..c9753d2 --- /dev/null +++ b/accounts/factories.py @@ -0,0 +1,11 @@ +import factory + +from .models import User + + +class UserFactory(factory.django.DjangoModelFactory): + class Meta: + model = User + + username = factory.Sequence(lambda n: f"user{n}") + email = factory.Faker("email") diff --git a/accounts/models.py b/accounts/models.py index b756e6d..8e64a97 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,3 +1,4 @@ +from django.db.models import QuerySet from django.contrib.auth.models import AbstractUser from django.utils.translation import gettext_lazy as _ @@ -10,3 +11,45 @@ class Meta: def __str__(self): return self.username + + def get_full_name(self) -> str: + return self.first_name + " " + self.last_name + + @property + def homes(self) -> QuerySet["homes.Home"]: + from homes.models import Home + + return Home.objects.filter(home_user_relations__user=self) + + @property + def can_add_activity(self) -> bool: + """Return True if the user can add an activity. + + A user can add an activity if they are a superuser or if they + are associated with at least one home. + """ + return self.is_superuser or self.homes.exists() + + def can_manage_residents(self, resident_ids: list[int]) -> bool: + """Return True if the user can manage the residents. + + A user can manage the residents if they are a superuser or if + they are associated with all of the residents' homes. + """ + from residents.models import Resident + from homes.models import HomeUserRelation + + if self.is_superuser: + return True + + residents = Resident.objects.filter(id__in=resident_ids) + + for resident in residents: + # Check if the user is associated with the resident's home + if HomeUserRelation.objects.filter( + home=resident.current_home, + user=self, + ).exists(): + return True + + return False diff --git a/accounts/tests.py b/accounts/tests.py index a39b155..4a7c313 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -1 +1,58 @@ -# Create your tests here. +from django.test import TestCase +from django.contrib.auth import get_user_model + +from residents.factories import ResidentFactory, ResidencyFactory +from homes.factories import HomeFactory, HomeUserRelationFactory + + +User = get_user_model() + + +class CanManageResidentsTest(TestCase): + def setUp(self): + self.superuser = User.objects.create_superuser( + username="superuser", + email="superuser@test.com", + password="testpassword", + ) + self.regular_user = User.objects.create_user( + username="regularuser", + email="regular@test.com", + password="testpassword", + ) + + self.home1 = HomeFactory() + self.home2 = HomeFactory() + + self.resident1 = ResidentFactory() + self.resident2 = ResidentFactory() + + ResidencyFactory( + resident=self.resident1, + home=self.home1, + ) + ResidencyFactory( + resident=self.resident2, + home=self.home2, + ) + + HomeUserRelationFactory( + home=self.home1, + user=self.regular_user, + ) + + def test_superuser_can_manage_all_residents(self): + resident_ids = [self.resident1.id, self.resident2.id] + self.assertTrue(self.superuser.can_manage_residents(resident_ids)) + + def test_regular_user_can_manage_associated_residents(self): + resident_ids = [ + self.resident1.id, + ] + self.assertTrue(self.regular_user.can_manage_residents(resident_ids)) + + def test_regular_user_cannot_manage_unassociated_residents(self): + resident_ids = [ + self.resident2.id, + ] + self.assertFalse(self.regular_user.can_manage_residents(resident_ids)) diff --git a/activities/views.py b/activities/views.py index 817b5f4..4e3f026 100644 --- a/activities/views.py +++ b/activities/views.py @@ -1,15 +1,16 @@ import uuid +from django.db import transaction +from django.contrib.auth.mixins import LoginRequiredMixin from django.urls import reverse_lazy from django.views.generic import ListView, FormView -from django.db import transaction from metrics.models import ResidentActivity from residents.models import Residency, Resident from metrics.forms import ResidentActivityForm -class ResidentActivityListView(ListView): +class ResidentActivityListView(LoginRequiredMixin, ListView): template_name = "activities/resident_activity_list.html" queryset = ResidentActivity.objects.all() context_object_name = "activities" @@ -17,11 +18,31 @@ class ResidentActivityListView(ListView): ordering = ["-activity_date"] -class ResidentActivityFormView(FormView): +class ResidentActivityFormView(LoginRequiredMixin, FormView): template_name = "activities/resident_activity_form.html" form_class = ResidentActivityForm success_url = reverse_lazy("activity-list-view") + # Check whether request user is authorized to view this page + def dispatch(self, request, *args, **kwargs): + if not request.user.can_add_activity: + return self.handle_no_permission() + + return super().dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + """Override the get_form_kwargs method to pass the user to the form. + + This will allow the form to filter the residents by the user's + homes or the superuser to filter by all homes. + """ + + kwargs = super().get_form_kwargs() + + kwargs["user"] = self.request.user + + return kwargs + @transaction.atomic def post(self, request, *args, **kwargs): """Override the post method to add the resident activity in the same @@ -38,6 +59,9 @@ def post(self, request, *args, **kwargs): # generate group activity ID based on current epoch time group_activity_id = uuid.uuid4() + if not request.user.can_manage_residents(resident_ids): + return self.handle_no_permission() + for resident_id in resident_ids: try: resident = Resident.objects.get(id=resident_id) diff --git a/core/settings.py b/core/settings.py index cb7219a..c7b3a6b 100644 --- a/core/settings.py +++ b/core/settings.py @@ -29,10 +29,22 @@ SECRET_KEY = "django-insecure-+24wlkd-xp!1)z)9#2=3gk+fhv-r9mo4*(kcfc=drz2=68m^-r" # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=[]) -CSRF_TRUSTED_ORIGINS = env.list("DJANGO_CSRF_TRUSTED_ORIGINS", default=[]) +DEBUG = env.bool( + "DJANGO_DEBUG", + default=True, +) + +ALLOWED_HOSTS = env.list( + "DJANGO_ALLOWED_HOSTS", + default=[ + "localhost", + "127.0.0.1", + ], +) +CSRF_TRUSTED_ORIGINS = env.list( + "DJANGO_CSRF_TRUSTED_ORIGINS", + default=[], +) INSTALLED_APPS = [ "django.contrib.admin", diff --git a/homes/admin.py b/homes/admin.py index 28ad5b2..638b7cd 100644 --- a/homes/admin.py +++ b/homes/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Home, HomeGroup +from .models import Home, HomeGroup, HomeUserRelation @admin.register(Home) @@ -8,6 +8,12 @@ class HomeAdmin(admin.ModelAdmin): pass +# register the HomeUserRelation model +@admin.register(HomeUserRelation) +class HomeUserRelationAdmin(admin.ModelAdmin): + pass + + # register the HomeGroup model @admin.register(HomeGroup) class HomeGroupAdmin(admin.ModelAdmin): diff --git a/homes/factories.py b/homes/factories.py index ef73ca0..c297657 100644 --- a/homes/factories.py +++ b/homes/factories.py @@ -1,10 +1,29 @@ import factory from factory import Sequence +from .models import Home, HomeGroup, HomeUserRelation + class HomeFactory(factory.django.DjangoModelFactory): class Meta: - model = "homes.Home" + model = Home django_get_or_create = ("name",) name: str = Sequence(lambda n: f"Home {n}") + + +class HomeGroupFactory(factory.django.DjangoModelFactory): + class Meta: + model = HomeGroup + django_get_or_create = ("name",) + + name: str = Sequence(lambda n: f"Home Group {n}") + + +class HomeUserRelationFactory(factory.django.DjangoModelFactory): + class Meta: + model = HomeUserRelation + django_get_or_create = ("home", "user") + + home: Home = factory.SubFactory(HomeFactory) + user: Home = factory.SubFactory(HomeFactory) diff --git a/homes/migrations/0007_homeuserrelation.py b/homes/migrations/0007_homeuserrelation.py new file mode 100644 index 0000000..9c77354 --- /dev/null +++ b/homes/migrations/0007_homeuserrelation.py @@ -0,0 +1,30 @@ +# Generated by Django 5.0 on 2024-01-04 19:24 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('homes', '0006_alter_home_home_group'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='HomeUserRelation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('home', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='home_user_relations', to='homes.home')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='home_user_relations', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'home user relation', + 'verbose_name_plural': 'home user relations', + 'db_table': 'home_user_relation', + 'unique_together': {('user', 'home')}, + }, + ), + ] diff --git a/homes/models.py b/homes/models.py index db0a32a..ef06aec 100644 --- a/homes/models.py +++ b/homes/models.py @@ -1,5 +1,6 @@ import datetime from typing import TYPE_CHECKING +from django.contrib.auth import get_user_model from django.db.models import Count, Q, QuerySet from django.utils import timezone from datetime import timedelta @@ -10,12 +11,16 @@ import pandas as pd from shortuuid.django_fields import ShortUUIDField + from core.constants import WEEK_DAYS, WEEKLY_ACTIVITY_RANGES if TYPE_CHECKING: from residents.models import Resident +user_model = get_user_model() + + def _generate_date_range(days_ago: int) -> list[datetime.date]: """Generates a list of dates starting from today and going back a specified number of days. @@ -199,6 +204,28 @@ def _structure_resident_data( } +class HomeUserRelation(models.Model): + user = models.ForeignKey( + to=user_model, + on_delete=models.CASCADE, + related_name="home_user_relations", + ) + home = models.ForeignKey( + to="homes.Home", + on_delete=models.CASCADE, + related_name="home_user_relations", + ) + + def __str__(self) -> str: + return f"{self.user} - {self.home}" + + class Meta: + db_table = "home_user_relation" + verbose_name = _("home user relation") + verbose_name_plural = _("home user relations") + unique_together = ("user", "home") + + class Home(models.Model): name = models.CharField(max_length=25) # add a foreign key relationship to HomeGroup @@ -226,6 +253,19 @@ def __str__(self) -> str: def get_absolute_url(self): return reverse("home-detail-view", kwargs={"url_uuid": self.url_uuid}) + @property + def members(self) -> QuerySet[user_model]: + """Returns a QuerySet of all members of this home.""" + return user_model.objects.filter(home_user_relations__home=self) + + def has_access(self, user: user_model) -> bool: + """Returns True if the user has access to this home. + + - Superusers have access to all homes. + - Members of the home have access to the home. + """ + return user.is_superuser or user in self.members.all() + @property def current_residents(self) -> models.QuerySet["Resident"]: """Returns a QuerySet of all current residents for this home.""" @@ -233,8 +273,8 @@ def current_residents(self) -> models.QuerySet["Resident"]: from residents.models import Resident return Resident.objects.filter( - residency__home=self, - residency__move_out__isnull=True, + residencies__home=self, + residencies__move_out__isnull=True, ).order_by("first_name") @property diff --git a/homes/templates/homes/home_group_list.html b/homes/templates/homes/home_group_list.html index 9da4cc5..7a725ff 100644 --- a/homes/templates/homes/home_group_list.html +++ b/homes/templates/homes/home_group_list.html @@ -3,18 +3,18 @@ {% load i18n %} {% block content %} -

Homes

+

{% translate "Homes" %}

{% translate "Homes showing the percent of residents by activity level" %}

- {% for home_group in home_groups %} + {% for home_group in home_groups_with_homes %}
- {{ home_group }} + {{ home_group.group_name }}
@@ -50,6 +49,7 @@

Homes

{{ home }}
+ {% if home.resident_counts_by_activity_level_chart_data %}
diff --git a/homes/tests.py b/homes/tests.py index 3bcc15f..165aacd 100644 --- a/homes/tests.py +++ b/homes/tests.py @@ -1,8 +1,12 @@ from datetime import date, timedelta +from http import HTTPStatus from io import StringIO from django.core.management import call_command -from django.test import TestCase + from django.core.management.base import CommandError +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse from django.utils import timezone from core.constants import WEEKLY_ACTIVITY_RANGES @@ -10,10 +14,12 @@ from metrics.models import ResidentActivity from residents.models import Residency, Resident -from .factories import HomeFactory -from .models import Home +from .factories import HomeFactory, HomeGroupFactory, HomeUserRelationFactory +from .models import Home, HomeGroup, HomeUserRelation from residents.factories import ResidentFactory, ResidencyFactory +User = get_user_model() + class HomeModelTests(TestCase): def setUp(self): @@ -321,3 +327,286 @@ def test_current_residents_with_recent_activity_metadata(self): [date.today() - timedelta(days=i) for i in range(7)], ) self.assertTrue(day_data["was_active"]) # Assuming activity every day + + +class HomeGroupListViewTest(TestCase): + def setUp(self): + self.url = reverse("home-list-view") + + # Setup test users + self.user = User.objects.create_user( + username="testuser", + password="password", + ) + self.superuser = User.objects.create_superuser( + username="admin", + password="admin", + ) + + # Setup test home groups using factories + self.home_group_with_multiple_homes = HomeGroupFactory( + name="Group with multiple homes", + ) + self.home_group_with_single_home = HomeGroupFactory( + name="Group with single home", + ) + self.home_group_without_homes = HomeGroupFactory(name="Group without homes") + + # Setup test homes using factories + # Homes in home_group_with_multiple_homes + self.home_in_group_with_multiple_homes1 = HomeFactory( + name="Home in multi-home group 1", + home_group=self.home_group_with_multiple_homes, + ) + self.home_in_group_with_multiple_homes2 = HomeFactory( + name="Home in multi-home group 2", + home_group=self.home_group_with_multiple_homes, + ) + + # Single home in home_group_with_single_home + self.home_in_group_with_single_home = HomeFactory( + name="Home in single-home group", + home_group=self.home_group_with_single_home, + ) + + # Home without any group + self.home_without_group = HomeFactory( + name="Home without group", + ) + + # Home not associated with any user + self.home_without_user = HomeFactory( + name="Home without user", + ) + + # Associate user with homes + HomeUserRelationFactory( + home=self.home_in_group_with_multiple_homes1, + user=self.user, + ) + HomeUserRelationFactory( + home=self.home_in_group_with_multiple_homes2, + user=self.user, + ) + HomeUserRelationFactory( + home=self.home_in_group_with_single_home, + user=self.user, + ) + HomeUserRelationFactory(home=self.home_without_group, user=self.user) + + def test_mock_data(self): + # Check the total count of Homes and HomeGroups + self.assertEqual(Home.objects.count(), 5) # Updated count + self.assertEqual(HomeGroup.objects.count(), 3) # Updated count + self.assertEqual(HomeUserRelation.objects.count(), 4) # Updated count + + # Assert that homes are correctly associated with their home groups + self.assertEqual( + self.home_in_group_with_multiple_homes1.home_group, + self.home_group_with_multiple_homes, + ) + self.assertEqual( + self.home_in_group_with_multiple_homes2.home_group, + self.home_group_with_multiple_homes, + ) + self.assertEqual( + self.home_in_group_with_single_home.home_group, + self.home_group_with_single_home, + ) + self.assertIsNone(self.home_without_group.home_group) + self.assertIsNone(self.home_without_user.home_group) + + # Check if the HomeGroup with no homes actually has no homes associated + self.assertFalse(self.home_group_without_homes.homes.exists()) + + # Assert user belongs to the correct number of homes + self.assertEqual(self.user.homes.count(), 4) # Updated count + + # Check if the specific homes are associated with the user + self.assertIn(self.home_in_group_with_multiple_homes1, self.user.homes.all()) + self.assertIn(self.home_in_group_with_multiple_homes2, self.user.homes.all()) + self.assertIn(self.home_in_group_with_single_home, self.user.homes.all()) + self.assertIn(self.home_without_group, self.user.homes.all()) + self.assertNotIn(self.home_without_user, self.user.homes.all()) + + # Assert superuser has no homes associated + self.assertEqual(self.superuser.homes.count(), 0) + + def test_context_data_for_regular_user(self): + # Log in as the regular user + self.client.login(username="testuser", password="password") + + # Get the response from the HomeGroupListView + response = self.client.get(self.url) + + # Check that the response status code is 200 + self.assertEqual(response.status_code, 200) + + # Extract the context data + context = response.context + + # Check that homes without a group are correctly in the context + homes_without_group = context["homes_without_group"] + self.assertIn(self.home_without_group, homes_without_group) + self.assertNotIn(self.home_without_user, homes_without_group) + + # Check that homes with a group are correctly in the context + homes_with_group = context["homes_with_group"] + self.assertIn(self.home_in_group_with_multiple_homes1, homes_with_group) + self.assertIn(self.home_in_group_with_multiple_homes2, homes_with_group) + self.assertIn(self.home_in_group_with_single_home, homes_with_group) + self.assertNotIn(self.home_without_user, homes_with_group) + + # Ensure that the home_groups_with_homes context is correctly formatted + home_groups_with_homes = context["home_groups_with_homes"] + + # Check for the presence of each group and their corresponding homes + for group in [ + self.home_group_with_multiple_homes, + self.home_group_with_single_home, + ]: + group_in_context = next( + (g for g in home_groups_with_homes if g["group_name"] == group.name), + None, + ) + self.assertIsNotNone( + group_in_context, + f"Group '{group.name}' not found in context.", + ) + if group == self.home_group_with_multiple_homes: + self.assertIn( + self.home_in_group_with_multiple_homes1, + group_in_context["homes"], + ) + self.assertIn( + self.home_in_group_with_multiple_homes2, + group_in_context["homes"], + ) + elif group == self.home_group_with_single_home: + self.assertIn( + self.home_in_group_with_single_home, + group_in_context["homes"], + ) + + def test_context_data_for_superuser(self): + # Log in as the superuser + self.client.login(username="admin", password="admin") + + # Get the response from the HomeGroupListView + response = self.client.get(self.url) + + # Check that the response status code is 200 + self.assertEqual(response.status_code, 200) + + # Extract the context data + context = response.context + + # Check that all homes are in the context data, regardless of home group + all_homes = Home.objects.all() + homes_without_group = context["homes_without_group"] + homes_with_group = context["homes_with_group"] + + for home in all_homes: + if home.home_group is None: + self.assertIn(home, homes_without_group) + else: + self.assertIn(home, homes_with_group) + + # Ensure that the home_groups_with_homes context is correctly formatted + home_groups_with_homes = context["home_groups_with_homes"] + + # Check for the presence of each group and their corresponding homes + for group in [ + self.home_group_with_multiple_homes, + self.home_group_with_single_home, + ]: + group_in_context = next( + (g for g in home_groups_with_homes if g["group_name"] == group.name), + None, + ) + self.assertIsNotNone( + group_in_context, + f"Group '{group.name}' not found in context.", + ) + + # Validate the homes within each group + if group == self.home_group_with_multiple_homes: + self.assertIn( + self.home_in_group_with_multiple_homes1, + group_in_context["homes"], + ) + self.assertIn( + self.home_in_group_with_multiple_homes2, + group_in_context["homes"], + ) + elif group == self.home_group_with_single_home: + self.assertIn( + self.home_in_group_with_single_home, + group_in_context["homes"], + ) + + # Validate the handling of the group without homes + self.assertFalse( + any( + group["group_name"] == self.home_group_without_homes.name + for group in home_groups_with_homes + ), + ) + + # Validate homes without groups + self.assertIn(self.home_without_group, homes_without_group) + self.assertIn(self.home_without_user, homes_without_group) + + def test_home_group_list_view_uses_correct_template(self): + self.client.login(username="testuser", password="password") + response = self.client.get(self.url) + self.assertTemplateUsed(response, "homes/home_group_list.html") + + +class HomeDetailViewTests(TestCase): + def setUp(self): + # Create users + self.regular_user = User.objects.create_user( + username="regular", + password="test", + ) + self.super_user = User.objects.create_superuser( + username="super", + password="test", + ) + self.member_user = User.objects.create_user(username="member", password="test") + + # Create a home + self.home = HomeFactory() + + self.url = reverse("home-detail-view", kwargs={"url_uuid": self.home.url_uuid}) + + # Create a relation where member_user is a member of home + HomeUserRelationFactory(home=self.home, user=self.member_user) + + def test_access_denied_non_member(self): + self.client.login(username="regular", password="test") + + response = self.client.get(self.url) + self.assertEqual( + response.status_code, + HTTPStatus.FORBIDDEN, + ) + + def test_access_granted_member(self): + self.client.login(username="member", password="test") + + response = self.client.get(self.url) + self.assertEqual( + response.status_code, + HTTPStatus.OK, + ) + + def test_access_granted_superuser(self): + self.client.login(username="super", password="test") + + response = self.client.get(self.url) + self.assertEqual( + response.status_code, + HTTPStatus.OK, + ) diff --git a/homes/views.py b/homes/views.py index 8f54e4b..0a650f0 100644 --- a/homes/views.py +++ b/homes/views.py @@ -1,8 +1,11 @@ from typing import Any + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import PermissionDenied from django.shortcuts import get_object_or_404 from django.views.generic.detail import DetailView -from django.views.generic.list import ListView +from django.views.generic.base import TemplateView from .charts import ( prepare_activity_counts_by_resident_and_activity_type_chart, @@ -14,23 +17,70 @@ prepare_work_by_type_chart, ) -from .models import Home, HomeGroup +from .models import Home + + +def regroup_homes_by_home_group(homes): + # group homes with group by group name + home_groups_with_homes = {} + + for home in homes: + if home.home_group.name not in home_groups_with_homes: + home_groups_with_homes[home.home_group.name] = [] + + home_groups_with_homes[home.home_group.name].append(home) + # Restructure home_groups_with_homes to a list of tuples + # to make it easier to iterate over in the template + home_groups_with_homes = [ + {"group_name": name, "homes": homes} + for name, homes in home_groups_with_homes.items() + ] -class HomeGroupListView(ListView): - model = HomeGroup - context_object_name = "home_groups" + return home_groups_with_homes + + +class HomeGroupListView(LoginRequiredMixin, TemplateView): template_name = "homes/home_group_list.html" def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) - context["homes_without_group"] = Home.objects.filter(home_group__isnull=True) + user = self.request.user + + if not user.is_authenticated: + return context + + if user.is_superuser: + context["homes_without_group"] = Home.objects.filter( + home_group__isnull=True, + ) + + context["homes_with_group"] = Home.objects.filter( + home_group__isnull=False, + ) + else: + context["homes_without_group"] = self.request.user.homes.filter( + home_group__isnull=True, + ) + + context["homes_with_group"] = self.request.user.homes.filter( + home_group__isnull=False, + ) + + home_groups_with_homes = regroup_homes_by_home_group( + context["homes_with_group"], + ) + + context["home_groups_with_homes"] = home_groups_with_homes return context -class HomeDetailView(DetailView): +# user should be logged in + + +class HomeDetailView(LoginRequiredMixin, DetailView): model = Home context_object_name = "home" @@ -45,11 +95,15 @@ def get_object(self, queryset=None): url_uuid=url_uuid, ) # Filter the queryset based on url_uuid - obj = get_object_or_404( + home = get_object_or_404( queryset, ) # Get the object or return a 404 error if not found - return obj + # ensure the user has access to the home + if not home.has_access(user=self.request.user): + raise PermissionDenied + + return home def prepare_activity_charts(self, context): """Prepare activity charts and add them to the template context.""" diff --git a/metrics/forms.py b/metrics/forms.py index c787a03..0696d05 100644 --- a/metrics/forms.py +++ b/metrics/forms.py @@ -1,8 +1,11 @@ # import the forms module from django from django import forms +from django.db.models import QuerySet from residents.models import Residency from .models import ResidentActivity +from django.contrib.auth import get_user_model +user_model = get_user_model() activity_type_choices = [ (choice[0], choice[1]) for choice in ResidentActivity.ActivityTypeChoices.choices @@ -12,38 +15,76 @@ ] -def get_resident_choices(): - # Fetch Residency objects with related 'home' and 'resident' in a single query - residencies = Residency.objects.filter(move_out__isnull=True).select_related( - "home", - "resident", - ) +def group_residents_by_home( + residencies: QuerySet[Residency], +) -> dict[str, list[tuple[int, str]]]: + """Group residents by home. + + Args: + residencies (QuerySet): A QuerySet of Residency objects with related 'home' and 'resident'. + Returns: + dict: A dictionary with home names as keys and a list of (resident_id, resident_name) tuples as values. + """ # Initialize a dictionary to group residents by home - resident_by_home = {} + residents_by_home = {} for residency in residencies: home_name = residency.home.name resident_name = residency.resident.full_name # Assuming full_name is a method - if home_name not in resident_by_home: - resident_by_home[home_name] = [] + if home_name not in residents_by_home: + residents_by_home[home_name] = [] - resident_by_home[home_name].append((residency.resident.id, resident_name)) + residents_by_home[home_name].append((residency.resident.id, resident_name)) - # Sort residents within each home + # Sort residents by name within each home resident_name_col_index = 1 - for home in resident_by_home: - resident_by_home[home].sort( + for home in residents_by_home: + residents_by_home[home].sort( key=lambda x: x[resident_name_col_index], - ) # Sort by resident name + ) + + return residents_by_home + + +def prepare_resident_choices(residencies: QuerySet[Residency]) -> list[tuple[int, str]]: + """Prepare a list of resident choices for a form. + + The list is sorted by home name and then by resident name. + + Args: + residencies (QuerySet): A QuerySet of Residency objects with related 'home' and 'resident'. + """ + residents_by_home = group_residents_by_home(residencies) # Sort the homes and convert the dictionary to the desired list format home_name_col_index = 0 resident_choices = sorted( - resident_by_home.items(), + residents_by_home.items(), key=lambda x: x[home_name_col_index], ) # Sort by home name + + return resident_choices + + +def get_resident_choices(user=None): + # Fetch Residency objects with related 'home' and 'resident' in a single query + if user.is_superuser: + residencies = Residency.objects.filter(move_out__isnull=True) + else: + residencies = Residency.objects.filter( + home__in=user.homes.all(), + move_out__isnull=True, + ) + + residencies.select_related( + "home", + "resident", + ) + + resident_choices = prepare_resident_choices(residencies=residencies) + return resident_choices @@ -60,6 +101,13 @@ class ResidentActivityForm(forms.Form): caregiver_role = forms.ChoiceField(choices=caregiver_role_choices) def __init__(self, *args, **kwargs): + """Initialize the form. + + Include the request user in the form kwargs if available so it + can be used to filter the resident choices. + """ + user = kwargs.pop("user", None) + super().__init__(*args, **kwargs) - self.fields["residents"].choices = get_resident_choices() + self.fields["residents"].choices = get_resident_choices(user=user) diff --git a/metrics/tests.py b/metrics/tests.py index 9059c24..1ef34fb 100644 --- a/metrics/tests.py +++ b/metrics/tests.py @@ -1,19 +1,49 @@ from http import HTTPStatus from django.test import TestCase +from metrics.forms import group_residents_by_home, prepare_resident_choices +from residents.models import Residency + from .models import ResidentActivity -from homes.factories import HomeFactory +from homes.factories import HomeFactory, HomeUserRelationFactory from residents.factories import ResidentFactory, ResidencyFactory from datetime import date from django.urls import reverse +from django.contrib.auth import get_user_model + +user_model = get_user_model() class ResidentActivityFormViewTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: + # Create a user + self.general_user = user_model.objects.create_user( + username="gerneraluser", + email="general@tzo.com", + password="testpassword", + ) + self.home_user = user_model.objects.create_user( + username="testuser", + email="test@email.com", + password="testpassword", + ) + self.superuser = user_model.objects.create_superuser( + username="superuser", + email="superuser@test.com", + password="superuserpassword", + ) + # Create test data using factories self.home1 = HomeFactory(name="Home 1") + + # Add the user to the home + HomeUserRelationFactory(home=self.home1, user=self.home_user) + + # Create two residents self.resident1 = ResidentFactory(first_name="Alice") self.resident2 = ResidentFactory(first_name="Bob") + + # Create a residency for each resident self.residency1 = ResidencyFactory( home=self.home1, resident=self.resident1, @@ -25,6 +55,41 @@ def setUp(self): move_out=None, ) + self.url = reverse("resident-activity-form-view") + + def test_general_user_gets_403(self): + """Test that a general user gets a 403 response.""" + # log in general user + self.client.force_login(self.general_user) + + # Make GET request + response = self.client.get(self.url) + + # The response should indicate a failure to process the form + self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) + + def test_home_user_gets_200(self): + """Test that a home user gets a 200 response.""" + # log in home user + self.client.force_login(self.home_user) + + # Make GET request + response = self.client.get(self.url) + + # The response should indicate a successful form submission + self.assertEqual(response.status_code, HTTPStatus.OK) + + def test_superuser_gets_200(self): + """Test that a superuser gets a 200 response.""" + # log in superuser + self.client.force_login(self.superuser) + + # Make GET request + response = self.client.get(self.url) + + # The response should indicate a successful form submission + self.assertEqual(response.status_code, HTTPStatus.OK) + def test_resident_activity_form_view_create_multiple_resident_activity(self): """Test that multiple resident activities can be created with one POST request.""" @@ -33,7 +98,7 @@ def test_resident_activity_form_view_create_multiple_resident_activity(self): activity_residents = [self.resident1.id, self.resident2.id] # Prepare data for POST request - self.data = { + data = { "residents": activity_residents, "activity_date": date.today(), "activity_type": ResidentActivity.ActivityTypeChoices.OUTDOOR, @@ -41,10 +106,13 @@ def test_resident_activity_form_view_create_multiple_resident_activity(self): "caregiver_role": ResidentActivity.CaregiverRoleChoices.NURSE, } + # log in superuser + self.client.force_login(self.superuser) + # Make POST request response = self.client.post( - reverse("resident-activity-form-view"), - self.data, + self.url, + data, ) # The response should indicate a successful form submission @@ -83,7 +151,7 @@ def test_activity_rollback_on_residency_exception(self): resident_activity_count_pre = ResidentActivity.objects.all().count() # Prepare data for POST request with a resident that does not have a residency - self.data = { + data = { "residents": [non_resident.id], "activity_type": ResidentActivity.ActivityTypeChoices.OUTDOOR, "activity_date": date.today(), @@ -91,10 +159,13 @@ def test_activity_rollback_on_residency_exception(self): "caregiver_role": ResidentActivity.CaregiverRoleChoices.NURSE, } + # log in superuser + self.client.force_login(self.superuser) + # Make POST request response = self.client.post( - reverse("resident-activity-form-view"), - self.data, + self.url, + data, ) # The response should indicate a failure to process the form @@ -119,3 +190,95 @@ def test_activity_rollback_on_residency_exception(self): # Ensure counts have not changed, indicating a rollback self.assertEqual(resident_activity_count_pre, resident_activity_count_post) + + def test_general_user_get_403_on_post(self): + """Test that a general user gets a 403 response. + + I.e., the user should not be associated with any residents and + so should not be authorized to submit the form. + """ + # log in general user + self.client.force_login(self.general_user) + + data = { + "residents": [self.resident1.id], + "activity_type": ResidentActivity.ActivityTypeChoices.OUTDOOR, + "activity_date": date.today(), + "activity_minutes": 30, + "caregiver_role": ResidentActivity.CaregiverRoleChoices.NURSE, + } + + # Make POST request + response = self.client.post( + self.url, + data, + ) + + # The response should indicate a failure to process the form + self.assertEqual( + response.status_code, + HTTPStatus.FORBIDDEN, + ) + + +class ResidentDataPreparationTest(TestCase): + def setUp(self): + # Create test homes + self.home1 = HomeFactory( + name="Home A", + ) + self.home2 = HomeFactory( + name="Home B", + ) + + # Create test residents + self.resident1 = ResidentFactory() + self.resident2 = ResidentFactory() + self.resident3 = ResidentFactory() + + # Create residencies + ResidencyFactory( + home=self.home1, + resident=self.resident1, + ) + ResidencyFactory( + home=self.home1, + resident=self.resident2, + ) + ResidencyFactory( + home=self.home2, + resident=self.resident3, + ) + + def test_group_residents_by_home(self): + residencies = Residency.objects.select_related("home", "resident").all() + grouped = group_residents_by_home(residencies) + + self.assertIn( + self.home1.name, + grouped, + ) + self.assertIn( + self.home2.name, + grouped, + ) + self.assertIn( + (self.resident1.id, self.resident1.full_name), + grouped[self.home1.name], + ) + self.assertIn( + (self.resident2.id, self.resident2.full_name), + grouped[self.home1.name], + ) + self.assertIn( + (self.resident3.id, self.resident3.full_name), + grouped[self.home2.name], + ) + + def test_prepare_resident_choices(self): + residencies = Residency.objects.select_related("home", "resident").all() + choices = prepare_resident_choices(residencies) + + # Assuming homes are sorted alphabetically in the choices list + self.assertEqual(choices[0][0], "Home A") + self.assertEqual(choices[1][0], "Home B") diff --git a/residents/migrations/0004_alter_residency_resident.py b/residents/migrations/0004_alter_residency_resident.py new file mode 100644 index 0000000..34b2874 --- /dev/null +++ b/residents/migrations/0004_alter_residency_resident.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0 on 2024-01-05 20:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('residents', '0003_resident_url_uuid'), + ] + + operations = [ + migrations.AlterField( + model_name='residency', + name='resident', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='residencies', to='residents.resident'), + ), + ] diff --git a/residents/models.py b/residents/models.py index 791bb79..1f3aa68 100644 --- a/residents/models.py +++ b/residents/models.py @@ -86,11 +86,22 @@ def activity_level(self): "text": text, } + @property + def current_residency(self): + """Return the resident's current residency.""" + return self.residencies.get(move_out__isnull=True) + + @property + def current_home(self): + """Return the resident's current home.""" + return self.current_residency.home + class Residency(models.Model): resident = models.ForeignKey( to=Resident, on_delete=models.PROTECT, + related_name="residencies", ) home = models.ForeignKey( to=Home, diff --git a/templates/403.html b/templates/403.html new file mode 100644 index 0000000..8b2d6a7 --- /dev/null +++ b/templates/403.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% load i18n %} + +{% block title %} + {% translate "Forbidden" %} +{% endblock %} + +{% block content %} +

+ {% translate "Forbidden" %} +

+ +

{% translate "You do not have permission to access this page." %}

+{% endblock %} diff --git a/templates/navigation.html b/templates/navigation.html index a6d75e7..9004d3b 100644 --- a/templates/navigation.html +++ b/templates/navigation.html @@ -6,7 +6,7 @@ GeriLife Caregiving -