diff --git a/castellum/appointments/mixins.py b/castellum/appointments/mixins.py index 1145ae937d959bb6b25175895e3c3c819cd7f5ff..54bee5d13d8500d7768cdfb2d494a9e56e64fab1 100644 --- a/castellum/appointments/mixins.py +++ b/castellum/appointments/mixins.py @@ -33,7 +33,7 @@ from castellum.castellum_auth.mixins import PermissionRequiredMixin from castellum.recruitment.mixins import ParticipationMixin from castellum.recruitment.models import Participation from castellum.utils import add_working_days -from castellum.utils.feeds import BaseICalFeed +from castellum.utils.feeds import BaseCalendarFeed from castellum.utils.mail import MailContext from .forms import AppointmentsForm @@ -137,12 +137,12 @@ class BaseCalendarView(DetailView): return super().get(request, *args, **kwargs) -class BaseAppointmentFeed(BaseICalFeed): - def items(self, obj): +class BaseAppointmentFeed(BaseCalendarFeed): + def items(self): return Appointment.objects.filter(participation__status=Participation.INVITED) - def item_start_datetime(self, item): + def item_start(self, item): return item.start - def item_end_datetime(self, item): + def item_end(self, item): return item.end diff --git a/castellum/castellum_auth/mixins.py b/castellum/castellum_auth/mixins.py index 13ef0ec86712b265670b5802cb8afecb78945d88..74c955f3d0db031a2123e1d8c4fdaaf4186d8b25 100644 --- a/castellum/castellum_auth/mixins.py +++ b/castellum/castellum_auth/mixins.py @@ -39,37 +39,25 @@ class PermissionRequiredMixin(auth_mixins.PermissionRequiredMixin): class APIAuthMixin(StrongholdPublicMixin): - """Similar to PermissionRequiredMixin. + """Authenticate with token header instead of login. - Differences: - - Does not redirect to login on error - - Gets request.user from the HTTP_AUTHORIZATION header + This should be the first mixin so that authentication is checked + before anything else. """ - permission_required = None - def handle_no_permission(self): - raise PermissionDenied + raise_exception = True - def get_permission_required(self): - return self.permission_required - - def get_permission_object(self): - return None - - def has_permission(self): - perms = self.get_permission_required() - permission_object = self.get_permission_object() - return self.request.user.has_perms(perms, obj=permission_object) - - def authenticate(self): + def get_token(self): auth = self.request.headers.get('Authorization', '').split() if len(auth) != 2 or auth[0].lower() != 'token': - self.handle_no_permission() + raise PermissionDenied + return auth[1] + def authenticate(self): try: - user = User.objects.get(token=auth[1]) + user = User.objects.get(token=self.get_token()) except User.DoesNotExist: - self.handle_no_permission() + raise PermissionDenied if not user.expiration_date: raise PermissionDenied('Account is not activated.') @@ -80,6 +68,11 @@ class APIAuthMixin(StrongholdPublicMixin): def dispatch(self, request, *args, **kwargs): self.request.user = self.authenticate() - if not self.has_permission(): - return self.handle_no_permission() return super().dispatch(request, *args, **kwargs) + + +class ParamAuthMixin(APIAuthMixin): + def get_token(self): + if 'token' not in self.request.GET: + raise PermissionDenied + return self.request.GET['token'] diff --git a/castellum/execution/api.py b/castellum/execution/api.py index 617fda9570719a3658bd9dc6b19daf1913961960..f7983b3e057a7086bb8a8dd739b249b164f086e2 100644 --- a/castellum/execution/api.py +++ b/castellum/execution/api.py @@ -30,6 +30,7 @@ from django.shortcuts import get_object_or_404 from django.views.generic import View from castellum.castellum_auth.mixins import APIAuthMixin +from castellum.castellum_auth.mixins import PermissionRequiredMixin from castellum.pseudonyms.helpers import get_pseudonym from castellum.pseudonyms.helpers import get_subject from castellum.pseudonyms.models import Domain @@ -42,7 +43,7 @@ from castellum.subjects.models import Subject monitoring_logger = logging.getLogger('monitoring.execution') -class BaseAPIView(StudyMixin, APIAuthMixin, View): +class BaseAPIView(APIAuthMixin, StudyMixin, PermissionRequiredMixin, View): permission_required = ['recruitment.conduct_study'] study_status = [Study.EXECUTION] diff --git a/castellum/execution/feeds.py b/castellum/execution/feeds.py index ce34798188981f8e3cf10e814bc3008bd19cf786..fbf34e07b4fb9148a952012c3a53031e6c491941 100644 --- a/castellum/execution/feeds.py +++ b/castellum/execution/feeds.py @@ -19,12 +19,13 @@ # License along with Castellum. If not, see # . -from django.core.exceptions import PermissionDenied -from django.shortcuts import get_object_or_404 from django.urls import reverse from django.utils.translation import gettext_lazy as _ from castellum.appointments.mixins import BaseAppointmentFeed +from castellum.castellum_auth.mixins import ParamAuthMixin +from castellum.castellum_auth.mixins import PermissionRequiredMixin +from castellum.studies.mixins import StudyMixin from castellum.studies.models import Study @@ -41,34 +42,26 @@ class BaseExecutionAppointmentFeed(BaseAppointmentFeed): item.session.study.pk, item.participation.pk ]) - def item_description(self, item): - return self.request.build_absolute_uri(self.item_link(item)) +class AppointmentFeedForStudy( + ParamAuthMixin, StudyMixin, PermissionRequiredMixin, BaseExecutionAppointmentFeed +): + permission_required = 'recruitment.conduct_study' + study_status = [Study.EXECUTION] -class AppointmentFeedForStudy(BaseExecutionAppointmentFeed): - def get_object(self, request, *args, **kwargs): - study = get_object_or_404(Study, pk=kwargs['pk'], status=Study.EXECUTION) - perms = ['recruitment.conduct_study', 'studies.access_study'] - if not self.request.user.has_perms(perms, obj=study): - raise PermissionDenied - return study - - def items(self, study): - return super().items(study)\ - .filter(session__study=study)\ + def items(self): + return super().items()\ + .filter(session__study=self.study)\ .select_related('participation__subject', 'session__study') -class AppointmentFeedForUser(BaseExecutionAppointmentFeed): - def get_object(self, request, *args, **kwargs): - return self.request.user - - def items(self, user): - items = super().items(user)\ +class AppointmentFeedForUser(ParamAuthMixin, BaseExecutionAppointmentFeed): + def items(self): + items = super().items()\ .filter(session__study__status=Study.EXECUTION)\ .select_related('participation__subject', 'session__study') perms = ['recruitment.conduct_study', 'studies.access_study'] for item in items: - if user.has_perms(perms, obj=item.session.study): + if self.request.user.has_perms(perms, obj=item.session.study): yield item diff --git a/castellum/execution/urls.py b/castellum/execution/urls.py index 51e98ff495404765df29f3e8921cbd26f38fc92e..af1e31c6df7475006a136cceba98bed0304610c6 100644 --- a/castellum/execution/urls.py +++ b/castellum/execution/urls.py @@ -52,12 +52,12 @@ from .views import TagView app_name = 'execution' urlpatterns = [ - path('calendar/feed/', AppointmentFeedForUser(), name='calendar-user-feed'), + path('calendar/feed/', AppointmentFeedForUser.as_view(), name='calendar-user-feed'), path('/', StudyDetailView.as_view(), name='study-detail'), path('/resolve/', ResolveView.as_view(), name='resolve'), path('/news/', NewsMailView.as_view(), name='news-mail'), path('/calendar/', CalendarView.as_view(), name='calendar'), - path('/calendar/feed/', AppointmentFeedForStudy(), name='calendar-feed'), + path('/calendar/feed/', AppointmentFeedForStudy.as_view(), name='calendar-feed'), path('/criteria/', ExclusionCriteriaView.as_view(), name='criteria'), path('/export/', ExportView.as_view(), name='export'), path('/progress/', ProgressView.as_view(), name='progress'), diff --git a/castellum/recruitment/feeds.py b/castellum/recruitment/feeds.py index 6b9d52a0ed96d0e1db55f2dcec9ac07ab132ca46..ba9cd5a1491b63c40472de6bb4c2933fe50f87bb 100644 --- a/castellum/recruitment/feeds.py +++ b/castellum/recruitment/feeds.py @@ -19,10 +19,12 @@ # License along with Castellum. If not, see # . -from django.core.exceptions import PermissionDenied from django.shortcuts import get_object_or_404 from castellum.appointments.mixins import BaseAppointmentFeed +from castellum.castellum_auth.mixins import ParamAuthMixin +from castellum.castellum_auth.mixins import PermissionRequiredMixin +from castellum.studies.mixins import StudyMixin from castellum.studies.models import Resource from castellum.studies.models import Study @@ -30,28 +32,21 @@ from .mixins import BaseFollowUpFeed from .models import Participation -class FollowUpFeedForStudy(BaseFollowUpFeed): - def get_object(self, request, *args, **kwargs): - study = get_object_or_404(Study, pk=kwargs.get('study_pk'), status=Study.EXECUTION) - perms = ['recruitment.recruit', 'studies.access_study'] - if not self.request.user.has_perms(perms, obj=study): - raise PermissionDenied - return study +class FollowUpFeedForStudy(ParamAuthMixin, StudyMixin, PermissionRequiredMixin, BaseFollowUpFeed): + permission_required = 'recruitment.recruit' + study_status = [Study.EXECUTION] - def items(self, study): + def items(self): return Participation.objects\ - .filter(study=study, followup_date__isnull=False)\ + .filter(study=self.study, followup_date__isnull=False)\ .order_by('-followup_date', '-followup_time') -class FollowUpFeedForUser(BaseFollowUpFeed): - def get_object(self, request, *args, **kwargs): - return self.request.user - - def items(self, user): +class FollowUpFeedForUser(ParamAuthMixin, BaseFollowUpFeed): + def items(self): perms = ['recruitment.recruit', 'studies.access_study'] for study in Study.objects.filter(status=Study.EXECUTION): - if not user.has_perms(perms, obj=study): + if not self.request.user.has_perms(perms, obj=study): continue qs = Participation.objects\ .filter(study=study, followup_date__isnull=False)\ @@ -60,29 +55,26 @@ class FollowUpFeedForUser(BaseFollowUpFeed): yield participation -class AppointmentFeed(BaseAppointmentFeed): - def get_object(self, request, *args, **kwargs): - study = get_object_or_404(Study, pk=kwargs['pk'], status=Study.EXECUTION) - perms = ['appointments.change_appointment', 'studies.access_study'] - if not self.request.user.has_perms(perms, obj=study): - raise PermissionDenied - return study +class AppointmentFeed(ParamAuthMixin, StudyMixin, PermissionRequiredMixin, BaseAppointmentFeed): + permission_required = 'appointments.change_appointment' + study_status = [Study.EXECUTION] - def items(self, study): - return super().items(study).filter(session__study=study) + def items(self): + return super().items().filter(session__study=self.study) - def item_link(self, item): + def item_title(self, item): + return '' + + def item_uid(self, item): # This is not really a usable link, but it is unique return '/appointments/{}/'.format(item.pk) -class ResourceFeed(BaseAppointmentFeed): - def get_object(self, request, *args, **kwargs): - return get_object_or_404(Resource, pk=kwargs['pk']) - - def items(self, resource): +class ResourceFeed(ParamAuthMixin, BaseAppointmentFeed): + def items(self): + resource = get_object_or_404(Resource, pk=self.kwargs['pk']) return ( - super().items(resource) + super().items() .filter(session__resource=resource) .exclude(session__study__status=Study.FINISHED) ) @@ -90,6 +82,6 @@ class ResourceFeed(BaseAppointmentFeed): def item_title(self, item): return item.session.study.name - def item_link(self, item): + def item_uid(self, item): # This is not really a usable link, but it is unique return '/appointments/{}/'.format(item.pk) diff --git a/castellum/recruitment/mixins.py b/castellum/recruitment/mixins.py index d3e446942366b4e484e0a91ca5a10aa7db46e196..4b3639f18eebe33e6e68174bdce7045ee6370ac1 100644 --- a/castellum/recruitment/mixins.py +++ b/castellum/recruitment/mixins.py @@ -40,7 +40,7 @@ from castellum.castellum_auth.mixins import PermissionRequiredMixin from castellum.studies.mixins import StudyMixin from castellum.subjects.mixins import SubjectMixin from castellum.subjects.models import Subject -from castellum.utils.feeds import BaseICalFeed +from castellum.utils.feeds import BaseCalendarFeed from castellum.utils.mail import MailContext from . import filter_queries @@ -146,25 +146,22 @@ class BaseAttributesUpdateView(PermissionRequiredMixin, UpdateView): return context -class BaseFollowUpFeed(BaseICalFeed): +class BaseFollowUpFeed(BaseCalendarFeed): def item_title(self, item): return '{} - Follow-Up'.format(item.study.name) - def item_start_datetime(self, item): + def item_start(self, item): if item.followup_time: return datetime.datetime.combine(item.followup_date, item.followup_time) else: return item.followup_date - def item_updateddate(self, item): + def item_updated_at(self, item): return item.updated_at def item_link(self, item): return reverse('recruitment:contact', args=[item.study.pk, item.pk]) - def item_description(self, item): - return self.request.build_absolute_uri(self.item_link(item)) - class BaseMailRecruitmentView(StudyMixin, PermissionRequiredMixin, FormView): template_name = 'recruitment/mail.html' diff --git a/castellum/recruitment/urls.py b/castellum/recruitment/urls.py index 261385b1884fe8c673f78cc8e57a4b36ed8234cb..877ef500c94bc004bad9a8b0306aa38c017f087c 100644 --- a/castellum/recruitment/urls.py +++ b/castellum/recruitment/urls.py @@ -44,8 +44,8 @@ from .views import SchedulerPingView app_name = 'recruitment' urlpatterns = [ - path('followups/', FollowUpFeedForUser(), name='followups-user-feed'), - path('resources//', ResourceFeed(), name='resource-feed'), + path('followups/', FollowUpFeedForUser.as_view(), name='followups-user-feed'), + path('resources//', ResourceFeed.as_view(), name='resource-feed'), path('scheduler-ping///', SchedulerPingView.as_view()), path('/', RecruitmentViewOpen.as_view(), name='recruitment-open'), path('/invited/', RecruitmentViewInvited.as_view(), name='recruitment-invited'), @@ -67,8 +67,8 @@ urlpatterns = [ ), path('/cleanup/', RecruitmentCleanupView.as_view(), name='cleanup'), path('/calendar/', CalendarView.as_view(), name='calendar'), - path('/calendar/feed/', AppointmentFeed(), name='calendar-feed'), - path('/followups/', FollowUpFeedForStudy(), name='followups-feed'), + path('/calendar/feed/', AppointmentFeed.as_view(), name='calendar-feed'), + path('/followups/', FollowUpFeedForStudy.as_view(), name='followups-feed'), path('//', ContactView.as_view(), name='contact'), path( '//appointments/', diff --git a/castellum/utils/feeds.py b/castellum/utils/feeds.py index 4c32e79212e7e755805bfaa1b2c22b5fb16bb962..078c4c0e95d1bbe7cec225197c1e0e6b8bb49ad4 100644 --- a/castellum/utils/feeds.py +++ b/castellum/utils/feeds.py @@ -19,26 +19,45 @@ # License along with Castellum. If not, see # . -from django.core.exceptions import PermissionDenied +from django.http import HttpResponse +from django.utils import timezone +from django.views.generic import View -from django_ical.views import ICalFeed -from stronghold.decorators import public +from icalendar import Calendar +from icalendar import Event -from castellum.castellum_auth.models import User +class BaseCalendarFeed(View): + def get(self, request, **kwargs): + cal = Calendar() + cal.add('version', '2.0') + cal.add('prodid', 'castellum') -@public -class BaseICalFeed(ICalFeed): - def __call__(self, request, *args, **kwargs): - self.request = request - try: - self.request.user = User.objects.get(token=request.GET.get('token')) - except User.DoesNotExist: - raise PermissionDenied - return super().__call__(request, *args, **kwargs) + for item in self.items(): + event = Event() + event.add('uid', request.build_absolute_uri(self.item_uid(item))) + event.add('dtstamp', self.item_updated_at(item)) + event.add('summary', self.item_title(item)) + event.add('dtstart', self.item_start(item)) + if hasattr(self, 'item_end'): + event.add('dtend', self.item_end(item)) + if hasattr(self, 'item_link'): + event.add('url', self._item_link(item)) + if hasattr(self, 'item_description'): + event.add('description', self.item_description(item)) + elif hasattr(self, 'item_link'): + event.add('description', self._item_link(item)) + cal.add_component(event) - def item_title(self, item): - return None + response = HttpResponse(content_type='text/calendar') + response.write(cal.to_ical()) + return response - def item_description(self, item): - return None + def item_updated_at(self, item): + return timezone.now() + + def item_uid(self, item): + return self.item_link(item) + + def _item_link(self, item): + return self.request.build_absolute_uri(self.item_link(item)) diff --git a/setup.cfg b/setup.cfg index f117bf6d5603f1925d16f90153549135fda3fe0b..8732600db003cf7f04ed9046ff1ef80f3e2f1624 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,7 +14,6 @@ install_requires = cologne-phonetics == 1.3.0 Django == 3.2.8 django-bootstrap4 == 3.0.1 - django-ical == 1.8.0 django-mfa3 == 0.3.0 django-npm == 1.0.0 django-parler == 2.2.0