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 %} -
{% translate "Homes showing the percent of residents by activity level" %}
- {% for home_group in home_groups %} + {% for home_group in home_groups_with_homes %}{% 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 -