apps/project/views.py
import loggingimport uuid from dateutil.relativedelta import relativedeltaimport django_filtersfrom django.conf import settingsfrom django.http import Http404from django.db import transaction, modelsfrom django.utils import timezonefrom django.utils.http import urlsafe_base64_decodefrom django.utils.encoding import force_textfrom django.contrib.postgres.fields.jsonb import KeyTextTransformfrom django.db.models.functions import Castfrom django.template.response import TemplateResponsefrom deep.permalinks import Permalinkfrom rest_framework.exceptions import PermissionDeniedfrom rest_framework import ( exceptions, filters, permissions, response, status, views, viewsets,)from rest_framework.decorators import actionfrom rest_framework.generics import get_object_or_404 from docs.utils import mark_as_list, mark_as_deleteimport ary.serializers as arys from deep.views import get_frontend_urlfrom deep.permissions import ( ModifyPermission, IsProjectMember,)from deep.serializers import URLCachedFileFieldfrom deep.paginations import SmallSizeSetPaginationfrom tabular.models import Field from user.utils import send_project_join_request_emailsfrom user.serializers import SimpleUserSerializerfrom user.models import Userfrom lead.models import Leadfrom lead.views import ProjectLeadGroupViewSetfrom geo.models import Regionfrom user_group.models import UserGroupfrom geo.serializers import RegionSerializerfrom entry.models import Entryfrom entry.views import ComprehensiveEntriesViewSetfrom analysis.models import ( Analysis, AnalyticalStatementEntry, DiscardedEntry) from .models import ( Project, ProjectRole, ProjectMembership, ProjectJoinRequest, ProjectUserGroupMembership, ProjectStats, ProjectOrganization)from .serializers import ( ProjectSerializer, ProjectStatSerializer, ProjectRoleSerializer, ProjectMembershipSerializer, ProjectJoinRequestSerializer, ProjectUserGroupSerializer, ProjectMemberViewSerializer, ProjectRecentActivitySerializer,)from .permissions import ( JoinPermission, AcceptRejectPermission, MembershipModifyPermission, PROJECT_PERMISSIONS,)from .filter_set import ( ProjectFilterSet, get_filtered_projects, ProjectMembershipFilterSet, ProjectUserGroupMembershipFilterSet,)from .tasks import generate_viz_stats from .token import project_request_token_generatorlogger = logging.getLogger(__name__) def _get_viz_data(request, project, can_view_confidential, token=None): """ Util function to trigger and serve Project entry/ary viz data """ if ( project.analysis_framework is None or project.analysis_framework.properties is None or project.analysis_framework.properties.get('stats_config') is None ): return { 'error': f'No configuration provided for current Project: {project.title}, Contact Admin', }, status.HTTP_404_NOT_FOUND stats, created = ProjectStats.objects.get_or_create(project=project) if token and ( not stats.public_share or token != str(stats.token) ): return { 'error': 'Token is invalid or sharing is disabled. Please contact project\'s admin.' }, status.HTTP_403_FORBIDDEN stat_file = stats.confidential_file if can_view_confidential else stats.file file_url = ( request.build_absolute_uri(URLCachedFileField().to_representation(stat_file)) if stat_file else None ) stats_meta = { 'data': file_url, 'modified_at': stats.modified_at, 'status': stats.status, 'public_share': stats.public_share, 'public_url': stats.get_public_url(request), } if stats.is_ready(): return stats_meta, status.HTTP_200_OK elif stats.status == ProjectStats.Status.FAILURE: return { 'error': 'Failed to generate stats, Contact Admin', **stats_meta, }, status.HTTP_200_OK transaction.on_commit(lambda: generate_viz_stats.delay(project.pk)) # NOTE: Not changing modified_at if already pending if stats.status != ProjectStats.Status.PENDING: stats.status = ProjectStats.Status.PENDING stats.save() return { 'message': 'Processing the request, try again later', **stats_meta, }, status.HTTP_202_ACCEPTED `ProjectViewSet` has 23 functions (exceeds 20 allowed). Consider refactoring.class ProjectViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated, ModifyPermission] filter_backends = (django_filters.rest_framework.DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter) filterset_class = ProjectFilterSet search_fields = ('title', 'description',) def get_queryset(self): return get_filtered_projects(self.request.user, self.request.GET) def get_serializer_class(self): # get the project and check for its member with current user if (self.lookup_url_kwarg or self.lookup_field) not in self.kwargs: return ProjectSerializer project = self.get_object() if project.members.filter(id=self.request.user.id).exists(): return ProjectMemberViewSerializer return ProjectSerializer def get_project_object(self): """ Return project same as get_object without any other filters """ if self.kwargs.get('pk') is not None: return get_object_or_404(self.get_queryset(), pk=self.kwargs['pk']) raise Http404 @action( detail=False, url_path='recent-activities', ) def get_recent_activities(self, request, version=None): return response.Response({ 'results': ProjectRecentActivitySerializer( Project.get_recent_activities(request.user), context={'request': request}, many=True, ).data }) """ Get list of projects that user is member of """ @action( detail=False, permission_classes=[permissions.IsAuthenticated], url_path='member-of', ) def get_for_member(self, request, version=None): user = self.request.GET.get('user') projects = Project.get_for_member(user) if user is None or request.user == user: projects = Project.get_for_member(request.user) else: projects = Project.get_for_public(request.user, user) user_group = request.GET.get('user_group') if user_group: user_group = user_group.split(',') projects = projects.filter(user_groups__id__in=user_group) self.page = self.paginate_queryset(projects) serializer = self.get_serializer(self.page, many=True) return self.get_paginated_response(serializer.data) """ Generate project public VIZ URL """ @action( detail=True, methods=['post'], url_path='public-viz', ) def generate_public_viz(self, request, pk=None, version=None): project = self.get_object() action = request.data.get('action', 'new') stats, created = ProjectStats.objects.get_or_create(project=project) if action == 'new': stats.public_share = True stats.token = uuid.uuid4() elif action == 'on': stats.public_share = True stats.token = stats.token or uuid.uuid4() elif action == 'off': stats.public_share = False else: raise exceptions.ValidationError({'action': f'Invalid action {action}'}) stats.save(update_fields=['token', 'public_share']) return response.Response({'public_url': stats.get_public_url(request)}) """ Get analysis framework for this project """ @action( detail=True, permission_classes=[permissions.IsAuthenticated], url_path='analysis-framework' ) def get_framework(self, request, pk=None, version=None): from analysis_framework.serializers import AnalysisFrameworkSerializer project = self.get_object() if not project.analysis_framework: raise exceptions.NotFound('Resource not found') serializer = AnalysisFrameworkSerializer( project.analysis_framework, context={'request': request}, ) return response.Response(serializer.data) """ Get regions assigned to this project """ @action( detail=True, url_path='regions', permission_classes=[permissions.IsAuthenticated], ) def get_regions(self, request, pk=None, version=None): instance = self.get_object() serializer = RegionSerializer( instance.regions, many=True, context={'request': request}, ) return response.Response({'regions': serializer.data}) """ Get assessment template for this project """ @action( detail=True, permission_classes=[permissions.IsAuthenticated], serializer_class=arys.AssessmentTemplateSerializer, url_path='assessment-template', ) def get_assessment_template(self, request, pk=None, version=None): project = self.get_object() if not project.assessment_template: raise exceptions.NotFound('Resource not found') serializer = arys.AssessmentTemplateSerializer( project.assessment_template, context={'request': request}, ) return response.Response(serializer.data) """ Get status for export: - tabular chart generation status """ @action( detail=True, permission_classes=[permissions.IsAuthenticated], serializer_class=ProjectJoinRequestSerializer, url_path='export-status', ) def get_export_status(self, request, pk=None, version=None): project = self.get_object() fields_pending_count = Field.objects.filter( cache__image_status=Field.CACHE_PENDING, sheet__book__project=project, ).count() return response.Response({ 'tabular_pending_fields_count': fields_pending_count, }) @action( detail=True, permission_classes=[permissions.IsAuthenticated], url_path='project-viz', ) def get_project_viz_data(self, request, pk=None, version=None): """ Get viz data for project entries: """ project = self.get_object() can_view_confidential = ( ProjectMembership.objects .filter(member=request.user, project=project) .annotate( view_all=models.F('role__lead_permissions').bitand(PROJECT_PERMISSIONS.lead.view) ) .filter(view_all=PROJECT_PERMISSIONS.lead.view) .exists() ) context, status_code = _get_viz_data(request, project, can_view_confidential) return response.Response(context, status=status_code) """ Join request to this project """ @action( detail=True, permission_classes=[permissions.IsAuthenticated, JoinPermission], methods=['post'], url_path='join', ) def join_project(self, request, pk=None, version=None): project = self.get_object() # Forbid join requests for private project if (project.is_private): raise PermissionDenied( {'message': "You cannot send join request to the private project"} ) serializer = ProjectJoinRequestSerializer( data={ 'role': ProjectRole.get_default_role().id, **request.data, }, context={'request': request, 'project': project} ) serializer.is_valid(raise_exception=True) join_request = serializer.save() serializer = ProjectJoinRequestSerializer( join_request, context={'request': request}, ) if settings.TESTING: send_project_join_request_emails(join_request.id) else: # Unless we are in test environment, # send the join request emails in a celery # background task. # This makes sure that the response is returned # while the emails are being sent in the background. def send_mail(): send_project_join_request_emails.delay(join_request.id) transaction.on_commit(send_mail) return response.Response(serializer.data, status=status.HTTP_201_CREATED) @staticmethod def _accept_request(responded_by, join_request, role): if not role or role == 'normal': role = ProjectRole.get_default_role() elif role == 'admin': role = ProjectRole.get_admin_role() else: role_qs = ProjectRole.objects.filter(id=role) if not role_qs.exists(): return response.Response( {'errors': 'Role id \'{}\' does not exist'.format(role)}, status=status.HTTP_404_NOT_FOUND ) role = role_qs.first() join_request.status = 'accepted' join_request.responded_by = responded_by join_request.responded_at = timezone.now() join_request.role = role join_request.save() ProjectMembership.objects.update_or_create( project=join_request.project, member=join_request.requested_by, defaults={ 'role': role, 'added_by': responded_by, }, ) @staticmethod def _reject_request(responded_by, join_request): join_request.status = 'rejected' join_request.responded_by = responded_by join_request.responded_at = timezone.now() join_request.save() """ Accept a join request to this project, creating the membership while doing so. """ @action( detail=True, permission_classes=[ permissions.IsAuthenticated, AcceptRejectPermission, ], methods=['post'], url_path=r'requests/(?P<request_id>\d+)/accept', ) def accept_request(self, request, pk=None, version=None, request_id=None): project = self.get_object() join_request = get_object_or_404(ProjectJoinRequest, id=request_id, project=project) if join_request.status in ['accepted', 'rejected']: raise exceptions.ValidationError( 'This request has already been {}'.format(join_request.status) ) role = request.data.get('role') ProjectViewSet._accept_request(request.user, join_request, role) serializer = ProjectJoinRequestSerializer( join_request, context={'request': request}, ) return response.Response(serializer.data) """ Reject a join request to this project """ @action( detail=True, permission_classes=[ permissions.IsAuthenticated, AcceptRejectPermission, ], methods=['post'], url_path=r'requests/(?P<request_id>\d+)/reject', ) def reject_request(self, request, pk=None, version=None, request_id=None): project = self.get_object() join_request = get_object_or_404(ProjectJoinRequest, id=request_id, project=project) if join_request.status in ['accepted', 'rejected']: raise exceptions.ValidationError( 'This request has already been {}'.format(join_request.status) ) ProjectViewSet._reject_request(request.user, join_request) serializer = ProjectJoinRequestSerializer( join_request, context={'request': request}, ) return response.Response(serializer.data) """ Cancel a join request to this project """ @mark_as_delete() @action( detail=True, permission_classes=[permissions.IsAuthenticated], methods=['post'], url_path=r'join/cancel', ) def cancel_request(self, request, pk=None, version=None, request_id=None): project = self.get_object() join_request = get_object_or_404(ProjectJoinRequest, requested_by=request.user, status='pending', project=project) if join_request.status in ['accepted', 'rejected']: raise exceptions.ValidationError( 'This request has already been {}'.format(join_request.status) ) join_request.delete() return response.Response(status=status.HTTP_204_NO_CONTENT) """ Get list of join requests for this project """ @mark_as_list() @action( detail=True, permission_classes=[ permissions.IsAuthenticated, ModifyPermission, ], url_path='requests', ) def get_requests(self, request, pk=None, version=None): project = self.get_object() join_requests = project.projectjoinrequest_set.all() self.page = self.paginate_queryset(join_requests) serializer = ProjectJoinRequestSerializer(self.page, many=True) return self.get_paginated_response(serializer.data) """ Comprehensive Entries """ @action( detail=True, permission_classes=[permissions.IsAuthenticated], methods=['get'], url_path=r'comprehensive-entries', ) def comprehensive_entries(self, request, *args, **kwargs): project = self.get_project_object() viewfn = ComprehensiveEntriesViewSet.as_view({'get': 'list'}) request._request.GET = request._request.GET.copy() request._request.GET['project'] = project.pk return viewfn(request._request, *args, **kwargs) @action( detail=True, permission_classes=[permissions.IsAuthenticated, IsProjectMember], url_path='members' ) def get_members(self, request, pk=None, version=None): project = self.get_object() members = User.objects.filter( models.Q(projectmembership__project=project) | models.Q(usergroup__projectusergroupmembership__project=project) ).distinct() self.page = self.paginate_queryset(members) serializer = SimpleUserSerializer(self.page, many=True) return self.get_paginated_response(serializer.data) """ Project Lead-Groups """ @action( detail=True, permission_classes=[permissions.IsAuthenticated], methods=['get'], url_path=r'lead-groups', ) def get_lead_groups(self, request, *args, **kwargs): project = self.get_project_object() viewfn = ProjectLeadGroupViewSet.as_view({'get': 'list'}) request._request.GET = request._request.GET.copy() request._request.GET['project'] = project.pk return viewfn(request._request) """ Project Questionnaire Meta """ @action( detail=True, permission_classes=[permissions.IsAuthenticated], methods=['get'], url_path=r'questionnaire-meta', ) def get_questionnaire_meta(self, request, *args, **kwargs): project = self.get_project_object() af = project.analysis_framework meta = { 'active_count': project.questionnaire_set.filter(is_archived=False).count(), 'archived_count': project.questionnaire_set.filter(is_archived=True).count(), 'analysis_framework': af and { 'id': af.id, 'title': af.title, }, } return response.Response(meta) """ Get analysis for this project """ @action( detail=True, permission_classes=[permissions.IsAuthenticated, IsProjectMember], url_path='analysis-overview' ) def get_analysis(self, request, pk=None, version=None): project = self.get_object() # get all the analysis in the project # TODO: Remove this later and let client handle this using graphql analysis_list = Analysis.objects.filter(project=project).values('id', 'title', 'created_at') total_sources = Lead.objects\ .filter(project=project)\ .annotate(entries_count=models.Count('entry'))\ .filter(entries_count__gt=0)\ .count() entries_total = Entry.objects.filter(project=project).count() entries_dragged = AnalyticalStatementEntry.objects\ .filter(analytical_statement__analysis_pillar__analysis__project=project)\ .order_by().values('entry').distinct() entries_discarded = DiscardedEntry.objects\ .filter(analysis_pillar__analysis__project=project)\ .order_by().values('entry').distinct() total_analyzed_entries = entries_discarded.union(entries_dragged).count() sources_discarded = DiscardedEntry.objects\ .filter(analysis_pillar__analysis__project=project)\ .order_by().values('entry__lead_id').distinct() sources_dragged = AnalyticalStatementEntry.objects\ .filter(analytical_statement__analysis_pillar__analysis__project=project)\ .order_by().values('entry__lead_id').distinct() total_analyzed_sources = sources_dragged.union(sources_discarded).count() lead_qs = Lead.objects\ .filter(project=project, authors__organization_type__isnull=False)\ .annotate( entries_count=models.functions.Coalesce(models.Subquery( AnalyticalStatementEntry.objects.filter( entry__lead_id=models.OuterRef('pk') ).order_by().values('entry__lead_id').annotate(count=models.Count('*')) .values('count')[:1], output_field=models.IntegerField(), ), 0) ).filter(entries_count__gt=0) authoring_organizations = Lead.objects\ .filter(id__in=lead_qs)\ .order_by('authors__organization_type').values('authors__organization_type')\ .annotate( count=models.Count('id'), organization_type_title=models.functions.Coalesce( models.F('authors__organization_type__title'), models.Value(''), )).values( 'count', 'organization_type_title', organization_type_id=models.F('authors__organization_type'), ) return response.Response({ 'analysis_list': analysis_list, 'entries_total': entries_total, 'analyzed_entries_count': total_analyzed_entries, 'sources_total': total_sources, 'analyzed_source_count': total_analyzed_sources, 'authoring_organizations': authoring_organizations }) class ProjectStatViewSet(ProjectViewSet): pagination_class = SmallSizeSetPagination def get_serializer_class(self): return ProjectStatSerializer def get_queryset(self): return get_filtered_projects( self.request.user, self.request.GET, annotate=True, ).prefetch_related( 'regions', 'organizations', ).select_related( 'created_by__profile', 'modified_by__profile' ) @action( detail=False, permission_classes=[permissions.IsAuthenticated], url_path='recent' ) def get_recent_projects(self, request, *args, **kwargs): # Only pull project data for which user is member of qs = self.get_queryset().filter(Project.get_query_for_member(request.user)) recent_projects = Project.get_recent_active_projects(request.user, qs) return response.Response( self.get_serializer_class()( recent_projects, context=self.get_serializer_context(), many=True, ).data ) @action( detail=False, permission_classes=[permissions.IsAuthenticated], url_path='summary' ) def get_projects_summary(self, request, pk=None, version=None): projects = Project.get_for_member(request.user) # Lead stats leads = Lead.objects.filter(project__in=projects) total_leads_tagged_count = leads.annotate(entries_count=models.Count('entry')).filter(entries_count__gt=0).count() total_leads_tagged_and_controlled_count = leads.annotate( entries_count=models.Count('entry'), controlled_entries_count=models.Count( 'entry', filter=models.Q(entry__controlled=True) ), ).filter(entries_count__gt=0, entries_count=models.F('controlled_entries_count')).count() # Entries activity recent_projects_id = list( projects.annotate( entries_count=Cast(KeyTextTransform('entries_activity', 'stats_cache'), models.IntegerField()) ).filter(entries_count__gt=0).order_by('-entries_count').values_list('id', flat=True)[:3]) recent_entries = Entry.objects.filter( project__in=recent_projects_id, created_at__gte=(timezone.now() + relativedelta(months=-3)) ) recent_entries_activity = { 'projects': ( recent_entries.order_by().values('project') .annotate(count=models.Count('*')) .filter(count__gt=0) .values('count', id=models.F('project'), title=models.F('project__title')) ), 'activities': ( recent_entries.order_by('project', 'created_at__date').values('project', 'created_at__date') .annotate(count=models.Count('*')) .values('project', 'count', date=models.Func(models.F('created_at__date'), function='DATE')) ), } return response.Response({ 'projects_count': projects.count(), 'total_leads_count': leads.count(), 'total_leads_tagged_count': total_leads_tagged_count, 'total_leads_tagged_and_controlled_count': total_leads_tagged_and_controlled_count, 'recent_entries_activity': recent_entries_activity, }) class ProjectMembershipViewSet(viewsets.ModelViewSet): serializer_class = ProjectMembershipSerializer permission_classes = [permissions.IsAuthenticated, ModifyPermission, MembershipModifyPermission] filter_backends = (django_filters.rest_framework.DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter) filterset_class = ProjectMembershipFilterSet def get_serializer(self, *args, **kwargs): data = kwargs.get('data') list = data and data.get('list') if list: kwargs.pop('data') kwargs.pop('many', None) return super().get_serializer( data=list, many=True, *args, **kwargs, ) return super().get_serializer( *args, **kwargs, ) def finalize_response(self, request, response, *args, **kwargs): if request.method == 'POST' and isinstance(response.data, list): response.data = { 'results': response.data, } return super().finalize_response( request, response, *args, **kwargs, ) def get_queryset(self): return ProjectMembership.get_for(self.request.user).filter(project=self.kwargs['project_id']).select_related( 'role' ) class ProjectOptionsView(views.APIView): """ Options for various attributes related to project """ permission_classes = [permissions.IsAuthenticated] Function `get` has a Cognitive Complexity of 16 (exceeds 12 allowed). Consider refactoring. def get(self, request, version=None): project_query = request.GET.get('project') fields_query = request.GET.get('fields') projects = None if project_query: projects = Project.get_for(request.user).filter( id__in=project_query.split(',') ) fields = None if fields_query: fields = fields_query.split(',') options = { 'project_organization_types': [ { 'key': s[0], 'value': s[1], } for s in ProjectOrganization.Type.choices ], } def _filter_by_projects(qs, projects): for p in projects: qs = qs.filter(project=p) return qs if (fields is None or 'regions' in fields): if projects: project_regions = _filter_by_projects(Region.objects, projects).distinct() else: project_regions = Region.objects.none() user_regions = Region.get_for(request.user) regions = Region.objects.filter(id__in=(project_regions | user_regions).values('id')).distinct() # regions = regions1.union(regions2).distinct() options['regions'] = [ { 'key': region.id, 'value': region.get_verbose_title(), } for region in regions ] if (fields is None or 'user_groups' in fields): if projects: project_user_groups = _filter_by_projects(UserGroup.objects, projects).distinct() else: project_user_groups = UserGroup.objects.none() user_user_groups = UserGroup.get_modifiable_for(request.user)\ .distinct() user_groups = UserGroup.objects.filter(id__in=(project_user_groups | user_user_groups).values('id')).distinct() # user_groups = user_groups1.union(user_groups2) options['user_groups'] = user_groups.distinct().annotate( key=models.F('id'), value=models.F('title') ).values('key', 'value') if (fields is None or 'involvement' in fields): options['involvement'] = [ {'key': 'my_projects', 'value': 'My projects'}, {'key': 'not_my_projects', 'value': 'Not my projects'} ] options['project_status'] = [ { 'key': value, 'value': label } for value, label in Project.Status.choices ] return response.Response(options) def accept_project_confirm( request, uidb64, pidb64, token, template_name='project/project_join_request_confirm.html',): accept = request.GET.get('accept', 'True').lower() == 'true' role = request.GET.get('role', 'normal') try: uid = force_text(urlsafe_base64_decode(uidb64)) pid = force_text(urlsafe_base64_decode(pidb64)) user = User.objects.get(pk=uid) join_request = ProjectJoinRequest.objects.get(pk=pid) except ( TypeError, ValueError, OverflowError, ProjectJoinRequest.DoesNotExist, User.DoesNotExist, ): user = None join_request = None request_data = { 'join_request': join_request, 'will_responded_by': user, } context = { 'title': 'Project Join Request', 'success': True, 'accept': accept, 'role': role, 'frontend_url': get_frontend_url(''), 'join_request': join_request, 'project_url': Permalink.project(join_request.project.id) if join_request else None, } if (join_request and user) is not None and\ project_request_token_generator.check_token(request_data, token): if accept: ProjectViewSet._accept_request(user, join_request, role) else: ProjectViewSet._reject_request(user, join_request) else: context['success'] = False return TemplateResponse(request, template_name, context) class ProjectRoleViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = ProjectRoleSerializer permission_classes = [permissions.IsAuthenticated] queryset = ProjectRole.objects.order_by('level') class ProjectUserGroupViewSet(viewsets.ModelViewSet): serializer_class = ProjectUserGroupSerializer permission_classes = [permissions.IsAuthenticated, ModifyPermission] filter_backends = (django_filters.rest_framework.DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter) queryset = ProjectUserGroupMembership.objects.all() filterset_class = ProjectUserGroupMembershipFilterSet def get_queryset(self): return ProjectUserGroupMembership.objects.filter(project=self.kwargs['project_id']).select_related( 'role' )