def prep_search( cls: Any, request: Request, project: "Project", extra_query_kwargs: Optional[Mapping[str, Any]] = None, ) -> Tuple[CursorResult, Mapping[str, Any]]: try: environment = cls._get_environment_from_request( request, project.organization_id) except Environment.DoesNotExist: result = CursorResult([], None, None, hits=0, max_hits=SEARCH_MAX_HITS) query_kwargs: MutableMapping[str, Any] = {} else: environments = [environment ] if environment is not None else environment query_kwargs = build_query_params_from_request(request, project.organization, [project], environments) if extra_query_kwargs is not None: assert "environment" not in extra_query_kwargs query_kwargs.update(extra_query_kwargs) query_kwargs["environments"] = environments result = search.query(**query_kwargs) return result, query_kwargs
def _count(self, request, query, organization, projects, environments, extra_query_kwargs=None): query_kwargs = {"projects": projects} query = query.strip() if query: search_filters = convert_query_values(parse_search_query(query), projects, request.user, environments) validate_search_filter_permissions(organization, search_filters, request.user) query_kwargs["search_filters"] = search_filters if extra_query_kwargs is not None: assert "environment" not in extra_query_kwargs query_kwargs.update(extra_query_kwargs) query_kwargs["environments"] = environments if environments else None query_kwargs["max_hits"] = ISSUES_COUNT_MAX_HITS_LIMIT result = search.query(**query_kwargs) return result.hits
def _search(self, request, project, extra_query_kwargs=None): try: environment = self._get_environment_from_request( request, project.organization_id, ) except Environment.DoesNotExist: # XXX: The 1000 magic number for `max_hits` is an abstraction leak # from `sentry.api.paginator.BasePaginator.get_result`. result = CursorResult([], None, None, hits=0, max_hits=1000) query_kwargs = {} else: environments = [environment ] if environment is not None else environment query_kwargs = build_query_params_from_request( request, project.organization, [project], environments, ) if extra_query_kwargs is not None: assert 'environment' not in extra_query_kwargs query_kwargs.update(extra_query_kwargs) query_kwargs['environments'] = environments result = search.query(**query_kwargs) return result, query_kwargs
def get(self, request, organization): with sentry_sdk.start_span(op="discover.endpoint", description="filter_params") as span: span.set_data("organization", organization) try: params = self.get_filter_params(request, organization) except NoProjects: return Response([]) possible_keys = ["transaction"] lookup_keys = {key: request.query_params.get(key) for key in possible_keys} if not any(lookup_keys.values()): return Response( { "detail": "Must provide one of {} in order to find related events".format( possible_keys ) }, status=400, ) try: with sentry_sdk.start_span(op="discover.endpoint", description="filter_creation"): projects = self.get_projects(request, organization) query_kwargs = build_query_params_from_request( request, organization, projects, params.get("environment") ) query_kwargs["limit"] = 5 try: # Need to escape quotes in case some "joker" has a transaction with quotes transaction_name = UNESCAPED_QUOTE_RE.sub('\\"', lookup_keys["transaction"]) parsed_terms = parse_search_query('transaction:"{}"'.format(transaction_name)) except ParseError: return Response({"detail": "Invalid transaction search"}, status=400) if query_kwargs.get("search_filters"): query_kwargs["search_filters"].extend(parsed_terms) else: query_kwargs["search_filters"] = parsed_terms with sentry_sdk.start_span(op="discover.endpoint", description="issue_search"): results = search.query(**query_kwargs) except discover.InvalidSearchQuery as err: raise ParseError(detail=six.text_type(err)) with sentry_sdk.start_span(op="discover.endpoint", description="serialize_results") as span: results = list(results) span.set_data("result_length", len(results)) context = serialize( results, request.user, GroupSerializer( environment_func=self._get_environment_func(request, organization.id) ), ) return Response(context)
def get(self, request, organization): try: # events-meta is still used by events v1 which doesn't require global views params = self.get_snuba_params(request, organization, check_global_views=False) except NoProjects: return Response([]) with sentry_sdk.start_span(op="discover.endpoint", description="find_lookup_keys") as span: possible_keys = ["transaction"] lookup_keys = {key: request.query_params.get(key) for key in possible_keys} if not any(lookup_keys.values()): return Response( { "detail": f"Must provide one of {possible_keys} in order to find related events" }, status=400, ) with self.handle_query_errors(): with sentry_sdk.start_span(op="discover.endpoint", description="filter_creation"): projects = self.get_projects(request, organization) query_kwargs = build_query_params_from_request( request, organization, projects, params.get("environment") ) query_kwargs["limit"] = 5 try: # Need to escape quotes in case some "joker" has a transaction with quotes transaction_name = UNESCAPED_QUOTE_RE.sub('\\"', lookup_keys["transaction"]) parsed_terms = parse_search_query(f'transaction:"{transaction_name}"') except ParseError: return Response({"detail": "Invalid transaction search"}, status=400) if query_kwargs.get("search_filters"): query_kwargs["search_filters"].extend(parsed_terms) else: query_kwargs["search_filters"] = parsed_terms with sentry_sdk.start_span(op="discover.endpoint", description="issue_search"): results = search.query(**query_kwargs) with sentry_sdk.start_span(op="discover.endpoint", description="serialize_results") as span: results = list(results) span.set_data("result_length", len(results)) context = serialize( results, request.user, GroupSerializer( environment_func=self._get_environment_func(request, organization.id) ), ) return Response(context)
def _search(self, request, project, extra_query_kwargs=None): query_kwargs = self._build_query_params_from_request(request, project) if extra_query_kwargs is not None: assert 'environment' not in extra_query_kwargs query_kwargs.update(extra_query_kwargs) try: query_kwargs['environment'] = self._get_environment_from_request( request, project.organization_id, ) except Environment.DoesNotExist: # XXX: The 1000 magic number for `max_hits` is an abstraction leak # from `sentry.api.paginator.BasePaginator.get_result`. result = CursorResult([], None, None, hits=0, max_hits=1000) else: result = search.query(**query_kwargs) return result, query_kwargs
def _search(self, request, project, extra_query_kwargs=None): query_kwargs = self._build_query_params_from_request(request, project) if extra_query_kwargs is not None: assert 'environment' not in extra_query_kwargs query_kwargs.update(extra_query_kwargs) try: if features.has('organizations:environments', project.organization, actor=request.user): query_kwargs['environment'] = self._get_environment_from_request( request, project.organization_id, ) except Environment.DoesNotExist: # XXX: The 1000 magic number for `max_hits` is an abstraction leak # from `sentry.api.paginator.BasePaginator.get_result`. result = CursorResult([], None, None, hits=0, max_hits=1000) else: result = search.query(**query_kwargs) return result, query_kwargs
def _search(self, request: Request, organization, projects, environments, extra_query_kwargs=None): query_kwargs = build_query_params_from_request(request, organization, projects, environments) if extra_query_kwargs is not None: assert "environment" not in extra_query_kwargs query_kwargs.update(extra_query_kwargs) query_kwargs["environments"] = environments if environments else None if query_kwargs["sort_by"] == "inbox": query_kwargs.pop("sort_by") result = inbox_search(**query_kwargs) else: result = search.query(**query_kwargs) return result, query_kwargs
def put(self, request, project): """ Bulk Mutate a List of Issues ```````````````````````````` Bulk mutate various attributes on issues. The list of issues to modify is given through the `id` query parameter. It is repeated for each issue that should be modified. - For non-status updates, the `id` query parameter is required. - For status updates, the `id` query parameter may be omitted for a batch "update all" query. - An optional `status` query parameter may be used to restrict mutations to only events with the given status. The following attributes can be modified and are supplied as JSON object in the body: If any ids are out of scope this operation will succeed without any data mutation. :qparam int id: a list of IDs of the issues to be mutated. This parameter shall be repeated for each issue. It is optional only if a status is mutated in which case an implicit `update all` is assumed. :qparam string status: optionally limits the query to issues of the specified status. Valid values are ``"resolved"``, ``"unresolved"`` and ``"ignored"``. :pparam string organization_slug: the slug of the organization the issues belong to. :pparam string project_slug: the slug of the project the issues belong to. :param string status: the new status for the issues. Valid values are ``"resolved"``, ``resolvedInNextRelease``, ``"unresolved"``, and ``"ignored"``. :param int ignoreDuration: the number of minutes to ignore this issue. :param boolean isPublic: sets the issue to public or private. :param boolean merge: allows to merge or unmerge different issues. :param string assignedTo: the username of the user that should be assigned to this issue. :param boolean hasSeen: in case this API call is invoked with a user context this allows changing of the flag that indicates if the user has seen the event. :param boolean isBookmarked: in case this API call is invoked with a user context this allows changing of the bookmark flag. :auth: required """ group_ids = request.GET.getlist('id') if group_ids: group_list = Group.objects.filter(project=project, id__in=group_ids) # filter down group ids to only valid matches group_ids = [g.id for g in group_list] if not group_ids: return Response(status=204) else: group_list = None serializer = GroupValidator( data=request.DATA, partial=True, context={'project': project}, ) if not serializer.is_valid(): return Response(serializer.errors, status=400) result = dict(serializer.object) acting_user = request.user if request.user.is_authenticated() else None if not group_ids: try: query_kwargs = self._build_query_params_from_request(request, project) except ValidationError as exc: return Response({'detail': six.text_type(exc)}, status=400) # bulk mutations are limited to 1000 items # TODO(dcramer): it'd be nice to support more than this, but its # a bit too complicated right now query_kwargs['limit'] = 1000 cursor_result = search.query(**query_kwargs) group_list = list(cursor_result) group_ids = [g.id for g in group_list] is_bulk = len(group_ids) > 1 queryset = Group.objects.filter( id__in=group_ids, ) statusDetails = result.pop('statusDetails', result) status = result.get('status') if status in ('resolved', 'resolvedInNextRelease'): if status == 'resolvedInNextRelease' or statusDetails.get('inNextRelease'): release = Release.objects.filter( projects=project, organization_id=project.organization_id, ).order_by('-date_added')[0] activity_type = Activity.SET_RESOLVED_IN_RELEASE activity_data = { # no version yet 'version': '', } status_details = { 'inNextRelease': True, } res_type = GroupResolution.Type.in_next_release res_status = GroupResolution.Status.pending elif statusDetails.get('inRelease'): release = statusDetails['inRelease'] activity_type = Activity.SET_RESOLVED_IN_RELEASE activity_data = { # no version yet 'version': release.version, } status_details = { 'inRelease': release.version, } res_type = GroupResolution.Type.in_release res_status = GroupResolution.Status.resolved else: release = None activity_type = Activity.SET_RESOLVED activity_data = {} status_details = {} now = timezone.now() for group in group_list: with transaction.atomic(): if release: resolution_params = { 'release': release, 'type': res_type, 'status': res_status, 'actor_id': request.user.id if request.user else None, } resolution, created = GroupResolution.objects.get_or_create( group=group, defaults=resolution_params, ) if not created: resolution.update( datetime=timezone.now(), **resolution_params ) else: resolution = None affected = Group.objects.filter( id=group.id, ).exclude( status=GroupStatus.RESOLVED, ).update( status=GroupStatus.RESOLVED, resolved_at=now, ) if not resolution: created = affected group.status = GroupStatus.RESOLVED group.resolved_at = now self._subscribe_and_assign_issue( acting_user, group, result ) if created: activity = Activity.objects.create( project=group.project, group=group, type=activity_type, user=acting_user, ident=resolution.id if resolution else None, data=activity_data, ) # TODO(dcramer): we need a solution for activity rollups # before sending notifications on bulk changes if not is_bulk: activity.send_notification() issue_resolved_in_release.send( group=group, project=project, sender=acting_user, ) result.update({ 'status': 'resolved', 'statusDetails': status_details, }) elif status: new_status = STATUS_CHOICES[result['status']] with transaction.atomic(): happened = queryset.exclude( status=new_status, ).update( status=new_status, ) GroupResolution.objects.filter( group__in=group_ids, ).delete() if new_status == GroupStatus.IGNORED: ignore_duration = ( statusDetails.pop('ignoreDuration', None) or statusDetails.pop('snoozeDuration', None) ) or None ignore_count = statusDetails.pop('ignoreCount', None) or None ignore_window = statusDetails.pop('ignoreWindow', None) or None ignore_user_count = statusDetails.pop('ignoreUserCount', None) or None ignore_user_window = statusDetails.pop('ignoreUserWindow', None) or None if ignore_duration or ignore_count or ignore_user_count: if ignore_duration: ignore_until = timezone.now() + timedelta( minutes=ignore_duration, ) else: ignore_until = None for group in group_list: state = {} if ignore_count and not ignore_window: state['times_seen'] = group.times_seen if ignore_user_count and not ignore_user_window: state['users_seen'] = group.count_users_seen() GroupSnooze.objects.create_or_update( group=group, values={ 'until': ignore_until, 'count': ignore_count, 'window': ignore_window, 'user_count': ignore_user_count, 'user_window': ignore_user_window, 'state': state, } ) result['statusDetails'] = { 'ignoreCount': ignore_count, 'ignoreUntil': ignore_until, 'ignoreUserCount': ignore_user_count, 'ignoreUserWindow': ignore_user_window, 'ignoreWindow': ignore_window, } else: GroupSnooze.objects.filter( group__in=group_ids, ).delete() ignore_until = None result['statusDetails'] = {} else: result['statusDetails'] = {} if group_list and happened: if new_status == GroupStatus.UNRESOLVED: activity_type = Activity.SET_UNRESOLVED activity_data = {} elif new_status == GroupStatus.IGNORED: activity_type = Activity.SET_IGNORED activity_data = { 'ignoreCount': ignore_count, 'ignoreDuration': ignore_duration, 'ignoreUntil': ignore_until, 'ignoreUserCount': ignore_user_count, 'ignoreUserWindow': ignore_user_window, 'ignoreWindow': ignore_window, } for group in group_list: group.status = new_status activity = Activity.objects.create( project=group.project, group=group, type=activity_type, user=acting_user, data=activity_data, ) # TODO(dcramer): we need a solution for activity rollups # before sending notifications on bulk changes if not is_bulk: if acting_user: GroupSubscription.objects.subscribe( user=acting_user, group=group, reason=GroupSubscriptionReason.status_change, ) activity.send_notification() if 'assignedTo' in result: if result['assignedTo']: for group in group_list: GroupAssignee.objects.assign(group, result['assignedTo'], acting_user) if 'isSubscribed' not in result or result['assignedTo'] != request.user: GroupSubscription.objects.subscribe( group=group, user=result['assignedTo'], reason=GroupSubscriptionReason.assigned, ) result['assignedTo'] = serialize(result['assignedTo']) else: for group in group_list: GroupAssignee.objects.deassign(group, acting_user) if result.get('hasSeen') and project.member_set.filter(user=acting_user).exists(): for group in group_list: instance, created = create_or_update( GroupSeen, group=group, user=acting_user, project=group.project, values={ 'last_seen': timezone.now(), } ) elif result.get('hasSeen') is False: GroupSeen.objects.filter( group__in=group_ids, user=acting_user, ).delete() if result.get('isBookmarked'): for group in group_list: GroupBookmark.objects.get_or_create( project=project, group=group, user=acting_user, ) GroupSubscription.objects.subscribe( user=acting_user, group=group, reason=GroupSubscriptionReason.bookmark, ) elif result.get('isBookmarked') is False: GroupBookmark.objects.filter( group__in=group_ids, user=acting_user, ).delete() # TODO(dcramer): we could make these more efficient by first # querying for rich rows are present (if N > 2), flipping the flag # on those rows, and then creating the missing rows if result.get('isSubscribed') in (True, False): is_subscribed = result['isSubscribed'] for group in group_list: # NOTE: Subscribing without an initiating event (assignment, # commenting, etc.) clears out the previous subscription reason # to avoid showing confusing messaging as a result of this # action. It'd be jarring to go directly from "you are not # subscribed" to "you were subscribed due since you were # assigned" just by clicking the "subscribe" button (and you # may no longer be assigned to the issue anyway.) GroupSubscription.objects.create_or_update( user=acting_user, group=group, project=project, values={ 'is_active': is_subscribed, 'reason': GroupSubscriptionReason.unknown, }, ) result['subscriptionDetails'] = { 'reason': SUBSCRIPTION_REASON_MAP.get( GroupSubscriptionReason.unknown, 'unknown', ), } if result.get('isPublic'): queryset.update(is_public=True) for group in group_list: if group.is_public: continue group.is_public = True Activity.objects.create( project=group.project, group=group, type=Activity.SET_PUBLIC, user=acting_user, ) elif result.get('isPublic') is False: queryset.update(is_public=False) for group in group_list: if not group.is_public: continue group.is_public = False Activity.objects.create( project=group.project, group=group, type=Activity.SET_PRIVATE, user=acting_user, ) # XXX(dcramer): this feels a bit shady like it should be its own # endpoint if result.get('merge') and len(group_list) > 1: primary_group = sorted(group_list, key=lambda x: -x.times_seen)[0] children = [] transaction_id = uuid4().hex for group in group_list: if group == primary_group: continue children.append(group) group.update(status=GroupStatus.PENDING_MERGE) merge_group.delay( from_object_id=group.id, to_object_id=primary_group.id, transaction_id=transaction_id, ) Activity.objects.create( project=primary_group.project, group=primary_group, type=Activity.MERGE, user=acting_user, data={ 'issues': [{'id': c.id} for c in children], }, ) result['merge'] = { 'parent': six.text_type(primary_group.id), 'children': [six.text_type(g.id) for g in children], } return Response(result)
def get(self, request, project): """ List a Project's Issues ``````````````````````` Return a list of issues (groups) bound to a project. All parameters are supplied as query string parameters. A default query of ``is:unresolved`` is applied. To return results with other statuses send an new query value (i.e. ``?query=`` for all results). The ``statsPeriod`` parameter can be used to select the timeline stats which should be present. Possible values are: '' (disable), '24h', '14d' :qparam string statsPeriod: an optional stat period (can be one of ``"24h"``, ``"14d"``, and ``""``). :qparam bool shortIdLookup: if this is set to true then short IDs are looked up by this function as well. This can cause the return value of the function to return an event issue of a different project which is why this is an opt-in. Set to `1` to enable. :qparam querystring query: an optional Sentry structured search query. If not provided an implied ``"is:unresolved"`` is assumed.) :pparam string organization_slug: the slug of the organization the issues belong to. :pparam string project_slug: the slug of the project the issues belong to. :auth: required """ stats_period = request.GET.get('statsPeriod') if stats_period not in (None, '', '24h', '14d'): return Response({"detail": ERR_INVALID_STATS_PERIOD}, status=400) elif stats_period is None: # default stats_period = '24h' elif stats_period == '': # disable stats stats_period = None query = request.GET.get('query', '').strip() if query: matching_group = None matching_event = None if len(query) == 32: # check to see if we've got an event ID try: mapping = EventMapping.objects.get( project_id=project.id, event_id=query, ) except EventMapping.DoesNotExist: pass else: matching_group = Group.objects.get(id=mapping.group_id) try: matching_event = Event.objects.get(event_id=query, project_id=project.id) except Event.DoesNotExist: pass # If the query looks like a short id, we want to provide some # information about where that is. Note that this can return # results for another project. The UI deals with this. elif request.GET.get('shortIdLookup') == '1' and \ looks_like_short_id(query): try: matching_group = Group.objects.by_qualified_short_id( project.organization_id, query) except Group.DoesNotExist: matching_group = None if matching_group is not None: response = Response(serialize( [matching_group], request.user, StreamGroupSerializer( stats_period=stats_period, matching_event_id=getattr(matching_event, 'id', None) ) )) response['X-Sentry-Direct-Hit'] = '1' return response try: query_kwargs = self._build_query_params_from_request(request, project) except ValidationError as exc: return Response({'detail': six.text_type(exc)}, status=400) cursor_result = search.query(**query_kwargs) results = list(cursor_result) context = serialize( results, request.user, StreamGroupSerializer( stats_period=stats_period ) ) # HACK: remove auto resolved entries if query_kwargs.get('status') == GroupStatus.UNRESOLVED: context = [ r for r in context if r['status'] == 'unresolved' ] response = Response(context) response['Link'] = ', '.join([ self.build_cursor_link(request, 'previous', cursor_result.prev), self.build_cursor_link(request, 'next', cursor_result.next), ]) if results and query not in SAVED_SEARCH_QUERIES: advanced_search.send(project=project, sender=request.user) return response
def put(self, request, project): """ Bulk Mutate a List of Issues ```````````````````````````` Bulk mutate various attributes on issues. The list of issues to modify is given through the `id` query parameter. It is repeated for each issue that should be modified. - For non-status updates, the `id` query parameter is required. - For status updates, the `id` query parameter may be omitted for a batch "update all" query. - An optional `status` query parameter may be used to restrict mutations to only events with the given status. The following attributes can be modified and are supplied as JSON object in the body: If any ids are out of scope this operation will succeed without any data mutation. :qparam int id: a list of IDs of the issues to be mutated. This parameter shall be repeated for each issue. It is optional only if a status is mutated in which case an implicit `update all` is assumed. :qparam string status: optionally limits the query to issues of the specified status. Valid values are ``"resolved"``, ``"unresolved"`` and ``"ignored"``. :pparam string organization_slug: the slug of the organization the issues belong to. :pparam string project_slug: the slug of the project the issues belong to. :param string status: the new status for the issues. Valid values are ``"resolved"``, ``"resolvedInNextRelease"``, ``"unresolved"``, and ``"ignored"``. :param int ignoreDuration: the number of minutes to ignore this issue. :param boolean isPublic: sets the issue to public or private. :param boolean merge: allows to merge or unmerge different issues. :param string assignedTo: the username of the user that should be assigned to this issue. :param boolean hasSeen: in case this API call is invoked with a user context this allows changing of the flag that indicates if the user has seen the event. :param boolean isBookmarked: in case this API call is invoked with a user context this allows changing of the bookmark flag. :auth: required """ group_ids = request.GET.getlist('id') if group_ids: group_list = Group.objects.filter( project=project, id__in=group_ids) # filter down group ids to only valid matches group_ids = [g.id for g in group_list] if not group_ids: return Response(status=204) else: group_list = None serializer = GroupValidator( data=request.DATA, partial=True, context={'project': project}, ) if not serializer.is_valid(): return Response(serializer.errors, status=400) result = dict(serializer.object) acting_user = request.user if request.user.is_authenticated() else None if not group_ids: try: query_kwargs = self._build_query_params_from_request( request, project) except ValidationError as exc: return Response({'detail': six.text_type(exc)}, status=400) # bulk mutations are limited to 1000 items # TODO(dcramer): it'd be nice to support more than this, but its # a bit too complicated right now limit = 1000 query_kwargs['limit'] = limit # the paginator has a default max_limit of 100, which must be overwritten. cursor_result = search.query( paginator_options={'max_limit': limit}, **query_kwargs) group_list = list(cursor_result) group_ids = [g.id for g in group_list] is_bulk = len(group_ids) > 1 queryset = Group.objects.filter( id__in=group_ids, ) discard = result.get('discard') if discard: if not features.has('projects:custom-filters', project, actor=request.user): return Response({'detail': ['You do not have that feature enabled']}, status=400) group_list = list(queryset) groups_to_delete = [] for group in group_list: with transaction.atomic(): try: tombstone = GroupTombstone.objects.create( previous_group_id=group.id, actor_id=acting_user.id if acting_user else None, **{name: getattr(group, name) for name in TOMBSTONE_FIELDS_FROM_GROUP} ) except IntegrityError: # in this case, a tombstone has already been created # for a group, so no hash updates are necessary pass else: groups_to_delete.append(group) GroupHash.objects.filter( group=group, ).update( group=None, group_tombstone_id=tombstone.id, ) self._delete_groups(request, project, groups_to_delete) return Response(status=204) statusDetails = result.pop('statusDetails', result) status = result.get('status') if status in ('resolved', 'resolvedInNextRelease'): if status == 'resolvedInNextRelease' or statusDetails.get('inNextRelease'): release = Release.objects.filter( projects=project, organization_id=project.organization_id, ).order_by('-date_added')[0] activity_type = Activity.SET_RESOLVED_IN_RELEASE activity_data = { # no version yet 'version': '', } status_details = { 'inNextRelease': True, 'actor': serialize(extract_lazy_object(request.user), request.user), } res_type = GroupResolution.Type.in_next_release res_status = GroupResolution.Status.pending elif statusDetails.get('inRelease'): release = statusDetails['inRelease'] activity_type = Activity.SET_RESOLVED_IN_RELEASE activity_data = { # no version yet 'version': release.version, } status_details = { 'inRelease': release.version, 'actor': serialize(extract_lazy_object(request.user), request.user), } res_type = GroupResolution.Type.in_release res_status = GroupResolution.Status.resolved else: release = None activity_type = Activity.SET_RESOLVED activity_data = {} status_details = {} now = timezone.now() for group in group_list: with transaction.atomic(): if release: resolution_params = { 'release': release, 'type': res_type, 'status': res_status, 'actor_id': request.user.id if request.user.is_authenticated() else None, } resolution, created = GroupResolution.objects.get_or_create( group=group, defaults=resolution_params, ) if not created: resolution.update( datetime=timezone.now(), **resolution_params) else: resolution = None affected = Group.objects.filter( id=group.id, ).update( status=GroupStatus.RESOLVED, resolved_at=now, ) if not resolution: created = affected group.status = GroupStatus.RESOLVED group.resolved_at = now self._subscribe_and_assign_issue( acting_user, group, result) if created: activity = Activity.objects.create( project=group.project, group=group, type=activity_type, user=acting_user, ident=resolution.id if resolution else None, data=activity_data, ) # TODO(dcramer): we need a solution for activity rollups # before sending notifications on bulk changes if not is_bulk: activity.send_notification() issue_resolved_in_release.send( group=group, project=project, sender=acting_user, ) result.update({ 'status': 'resolved', 'statusDetails': status_details, }) elif status: new_status = STATUS_CHOICES[result['status']] with transaction.atomic(): happened = queryset.exclude( status=new_status, ).update( status=new_status, ) GroupResolution.objects.filter( group__in=group_ids, ).delete() if new_status == GroupStatus.IGNORED: ignore_duration = ( statusDetails.pop('ignoreDuration', None) or statusDetails.pop('snoozeDuration', None) ) or None ignore_count = statusDetails.pop( 'ignoreCount', None) or None ignore_window = statusDetails.pop( 'ignoreWindow', None) or None ignore_user_count = statusDetails.pop( 'ignoreUserCount', None) or None ignore_user_window = statusDetails.pop( 'ignoreUserWindow', None) or None if ignore_duration or ignore_count or ignore_user_count: if ignore_duration: ignore_until = timezone.now() + timedelta( minutes=ignore_duration, ) else: ignore_until = None for group in group_list: state = {} if ignore_count and not ignore_window: state['times_seen'] = group.times_seen if ignore_user_count and not ignore_user_window: state['users_seen'] = group.count_users_seen() GroupSnooze.objects.create_or_update( group=group, values={ 'until': ignore_until, 'count': ignore_count, 'window': ignore_window, 'user_count': ignore_user_count, 'user_window': ignore_user_window, 'state': state, 'actor_id': request.user.id if request.user.is_authenticated() else None, } ) result['statusDetails'] = { 'ignoreCount': ignore_count, 'ignoreUntil': ignore_until, 'ignoreUserCount': ignore_user_count, 'ignoreUserWindow': ignore_user_window, 'ignoreWindow': ignore_window, 'actor': serialize(extract_lazy_object(request.user), request.user), } else: GroupSnooze.objects.filter( group__in=group_ids, ).delete() ignore_until = None result['statusDetails'] = {} else: result['statusDetails'] = {} if group_list and happened: if new_status == GroupStatus.UNRESOLVED: activity_type = Activity.SET_UNRESOLVED activity_data = {} elif new_status == GroupStatus.IGNORED: activity_type = Activity.SET_IGNORED activity_data = { 'ignoreCount': ignore_count, 'ignoreDuration': ignore_duration, 'ignoreUntil': ignore_until, 'ignoreUserCount': ignore_user_count, 'ignoreUserWindow': ignore_user_window, 'ignoreWindow': ignore_window, } for group in group_list: group.status = new_status activity = Activity.objects.create( project=group.project, group=group, type=activity_type, user=acting_user, data=activity_data, ) # TODO(dcramer): we need a solution for activity rollups # before sending notifications on bulk changes if not is_bulk: if acting_user: GroupSubscription.objects.subscribe( user=acting_user, group=group, reason=GroupSubscriptionReason.status_change, ) activity.send_notification() if 'assignedTo' in result: if result['assignedTo']: for group in group_list: GroupAssignee.objects.assign( group, result['assignedTo'], acting_user) if 'isSubscribed' not in result or result['assignedTo'] != request.user: GroupSubscription.objects.subscribe( group=group, user=result['assignedTo'], reason=GroupSubscriptionReason.assigned, ) result['assignedTo'] = serialize(result['assignedTo']) else: for group in group_list: GroupAssignee.objects.deassign(group, acting_user) if result.get('hasSeen') and project.member_set.filter(user=acting_user).exists(): for group in group_list: instance, created = create_or_update( GroupSeen, group=group, user=acting_user, project=group.project, values={ 'last_seen': timezone.now(), } ) elif result.get('hasSeen') is False: GroupSeen.objects.filter( group__in=group_ids, user=acting_user, ).delete() if result.get('isBookmarked'): for group in group_list: GroupBookmark.objects.get_or_create( project=project, group=group, user=acting_user, ) GroupSubscription.objects.subscribe( user=acting_user, group=group, reason=GroupSubscriptionReason.bookmark, ) elif result.get('isBookmarked') is False: GroupBookmark.objects.filter( group__in=group_ids, user=acting_user, ).delete() # TODO(dcramer): we could make these more efficient by first # querying for rich rows are present (if N > 2), flipping the flag # on those rows, and then creating the missing rows if result.get('isSubscribed') in (True, False): is_subscribed = result['isSubscribed'] for group in group_list: # NOTE: Subscribing without an initiating event (assignment, # commenting, etc.) clears out the previous subscription reason # to avoid showing confusing messaging as a result of this # action. It'd be jarring to go directly from "you are not # subscribed" to "you were subscribed due since you were # assigned" just by clicking the "subscribe" button (and you # may no longer be assigned to the issue anyway.) GroupSubscription.objects.create_or_update( user=acting_user, group=group, project=project, values={ 'is_active': is_subscribed, 'reason': GroupSubscriptionReason.unknown, }, ) result['subscriptionDetails'] = { 'reason': SUBSCRIPTION_REASON_MAP.get( GroupSubscriptionReason.unknown, 'unknown', ), } if 'isPublic' in result: # We always want to delete an existing share, because triggering # an isPublic=True even when it's already public, should trigger # regenerating. for group in group_list: if GroupShare.objects.filter(group=group).delete(): result['shareId'] = None Activity.objects.create( project=group.project, group=group, type=Activity.SET_PRIVATE, user=acting_user, ) if result.get('isPublic'): for group in group_list: share, created = GroupShare.objects.get_or_create( project=group.project, group=group, user=acting_user, ) if created: result['shareId'] = share.uuid Activity.objects.create( project=group.project, group=group, type=Activity.SET_PUBLIC, user=acting_user, ) # XXX(dcramer): this feels a bit shady like it should be its own # endpoint if result.get('merge') and len(group_list) > 1: primary_group = sorted(group_list, key=lambda x: -x.times_seen)[0] children = [] transaction_id = uuid4().hex for group in group_list: if group == primary_group: continue children.append(group) group.update(status=GroupStatus.PENDING_MERGE) merge_group.delay( from_object_id=group.id, to_object_id=primary_group.id, transaction_id=transaction_id, ) Activity.objects.create( project=primary_group.project, group=primary_group, type=Activity.MERGE, user=acting_user, data={ 'issues': [{ 'id': c.id } for c in children], }, ) result['merge'] = { 'parent': six.text_type(primary_group.id), 'children': [six.text_type(g.id) for g in children], } return Response(result)
def get(self, request, project): """ List a Project's Issues ``````````````````````` Return a list of issues (groups) bound to a project. All parameters are supplied as query string parameters. A default query of ``is:unresolved`` is applied. To return results with other statuses send an new query value (i.e. ``?query=`` for all results). The ``statsPeriod`` parameter can be used to select the timeline stats which should be present. Possible values are: '' (disable), '24h', '14d' :qparam string statsPeriod: an optional stat period (can be one of ``"24h"``, ``"14d"``, and ``""``). :qparam bool shortIdLookup: if this is set to true then short IDs are looked up by this function as well. This can cause the return value of the function to return an event issue of a different project which is why this is an opt-in. Set to `1` to enable. :qparam querystring query: an optional Sentry structured search query. If not provided an implied ``"is:unresolved"`` is assumed.) :pparam string organization_slug: the slug of the organization the issues belong to. :pparam string project_slug: the slug of the project the issues belong to. :auth: required """ stats_period = request.GET.get('statsPeriod') if stats_period not in (None, '', '24h', '14d'): return Response({"detail": ERR_INVALID_STATS_PERIOD}, status=400) elif stats_period is None: # default stats_period = '24h' elif stats_period == '': # disable stats stats_period = None query = request.GET.get('query', '').strip() if query: matching_group = None matching_event = None if len(query) == 32: # check to see if we've got an event ID try: matching_group = Group.objects.from_event_id(project, query) except Group.DoesNotExist: pass else: try: matching_event = Event.objects.get( event_id=query, project_id=project.id) except Event.DoesNotExist: pass # If the query looks like a short id, we want to provide some # information about where that is. Note that this can return # results for another project. The UI deals with this. elif request.GET.get('shortIdLookup') == '1' and \ looks_like_short_id(query): try: matching_group = Group.objects.by_qualified_short_id( project.organization_id, query ) except Group.DoesNotExist: matching_group = None if matching_group is not None: response = Response( serialize( [matching_group], request.user, StreamGroupSerializer( stats_period=stats_period, matching_event_id=getattr( matching_event, 'id', None) ) ) ) response['X-Sentry-Direct-Hit'] = '1' return response try: query_kwargs = self._build_query_params_from_request( request, project) except ValidationError as exc: return Response({'detail': six.text_type(exc)}, status=400) cursor_result = search.query(count_hits=True, **query_kwargs) results = list(cursor_result) context = serialize( results, request.user, StreamGroupSerializer( stats_period=stats_period)) # HACK: remove auto resolved entries if query_kwargs.get('status') == GroupStatus.UNRESOLVED: context = [r for r in context if r['status'] == 'unresolved'] response = Response(context) self.add_cursor_headers(request, response, cursor_result) if results and query not in SAVED_SEARCH_QUERIES: advanced_search.send(project=project, sender=request.user) return response
def put(self, request, project): """ Bulk Mutate a List of Issues ```````````````````````````` Bulk mutate various attributes on issues. The list of issues to modify is given through the `id` query parameter. It is repeated for each issue that should be modified. - For non-status updates, the `id` query parameter is required. - For status updates, the `id` query parameter may be omitted for a batch "update all" query. - An optional `status` query parameter may be used to restrict mutations to only events with the given status. The following attributes can be modified and are supplied as JSON object in the body: If any ids are out of scope this operation will succeed without any data mutation. :qparam int id: a list of IDs of the issues to be mutated. This parameter shall be repeated for each issue. It is optional only if a status is mutated in which case an implicit `update all` is assumed. :qparam string status: optionally limits the query to issues of the specified status. Valid values are ``"resolved"``, ``"unresolved"`` and ``"ignored"``. :pparam string organization_slug: the slug of the organization the issues belong to. :pparam string project_slug: the slug of the project the issues belong to. :param string status: the new status for the issues. Valid values are ``"resolved"``, ``resolvedInNextRelease``, ``"unresolved"``, and ``"ignored"``. :param int ignoreDuration: the number of minutes to ignore this issue. :param boolean isPublic: sets the issue to public or private. :param boolean merge: allows to merge or unmerge different issues. :param string assignedTo: the username of the user that should be assigned to this issue. :param boolean hasSeen: in case this API call is invoked with a user context this allows changing of the flag that indicates if the user has seen the event. :param boolean isBookmarked: in case this API call is invoked with a user context this allows changing of the bookmark flag. :auth: required """ group_ids = request.GET.getlist('id') if group_ids: group_list = Group.objects.filter(project=project, id__in=group_ids) # filter down group ids to only valid matches group_ids = [g.id for g in group_list] if not group_ids: return Response(status=204) else: group_list = None serializer = GroupValidator( data=request.DATA, partial=True, context={'project': project}, ) if not serializer.is_valid(): return Response(serializer.errors, status=400) result = dict(serializer.object) acting_user = request.user if request.user.is_authenticated() else None if not group_ids: try: query_kwargs = self._build_query_params_from_request(request, project) except ValidationError as exc: return Response({'detail': six.text_type(exc)}, status=400) # bulk mutations are limited to 1000 items # TODO(dcramer): it'd be nice to support more than this, but its # a bit too complicated right now query_kwargs['limit'] = 1000 cursor_result = search.query(**query_kwargs) group_list = list(cursor_result) group_ids = [g.id for g in group_list] is_bulk = len(group_ids) > 1 queryset = Group.objects.filter( id__in=group_ids, ) if result.get('status') == 'resolvedInNextRelease': try: release = Release.objects.filter( projects=project, organization_id=project.organization_id ).order_by('-date_added')[0] except IndexError: return Response('{"detail": "No release data present in the system to form a basis for \'Next Release\'"}', status=400) now = timezone.now() for group in group_list: try: with transaction.atomic(): resolution, created = GroupResolution.objects.create( group=group, release=release, ), True except IntegrityError: resolution, created = GroupResolution.objects.get( group=group, ), False self._subscribe_and_assign_issue( acting_user, group, result ) if created: activity = Activity.objects.create( project=group.project, group=group, type=Activity.SET_RESOLVED_IN_RELEASE, user=acting_user, ident=resolution.id, data={ # no version yet 'version': '', } ) # TODO(dcramer): we need a solution for activity rollups # before sending notifications on bulk changes if not is_bulk: activity.send_notification() issue_resolved_in_release.send(project=project, sender=acting_user) queryset.update( status=GroupStatus.RESOLVED, resolved_at=now, ) result.update({ 'status': 'resolved', 'statusDetails': { 'inNextRelease': True, }, }) elif result.get('status') == 'resolved': now = timezone.now() happened = queryset.exclude( status=GroupStatus.RESOLVED, ).update( status=GroupStatus.RESOLVED, resolved_at=now, ) GroupResolution.objects.filter( group__in=group_ids, ).delete() if group_list and happened: for group in group_list: group.status = GroupStatus.RESOLVED group.resolved_at = now self._subscribe_and_assign_issue( acting_user, group, result ) activity = Activity.objects.create( project=group.project, group=group, type=Activity.SET_RESOLVED, user=acting_user, ) # TODO(dcramer): we need a solution for activity rollups # before sending notifications on bulk changes if not is_bulk: activity.send_notification() result['statusDetails'] = {} elif result.get('status'): new_status = STATUS_CHOICES[result['status']] happened = queryset.exclude( status=new_status, ).update( status=new_status, ) GroupResolution.objects.filter( group__in=group_ids, ).delete() if new_status == GroupStatus.IGNORED: ignore_duration = ( result.pop('ignoreDuration', None) or result.pop('snoozeDuration', None) ) if ignore_duration: ignore_until = timezone.now() + timedelta( minutes=ignore_duration, ) for group in group_list: GroupSnooze.objects.create_or_update( group=group, values={ 'until': ignore_until, } ) result['statusDetails'] = { 'ignoreUntil': ignore_until, } else: GroupSnooze.objects.filter( group__in=group_ids, ).delete() ignore_until = None result['statusDetails'] = {} else: result['statusDetails'] = {} if group_list and happened: if new_status == GroupStatus.UNRESOLVED: activity_type = Activity.SET_UNRESOLVED activity_data = {} elif new_status == GroupStatus.IGNORED: activity_type = Activity.SET_IGNORED activity_data = { 'ignoreUntil': ignore_until, 'ignoreDuration': ignore_duration, } for group in group_list: group.status = new_status activity = Activity.objects.create( project=group.project, group=group, type=activity_type, user=acting_user, data=activity_data, ) # TODO(dcramer): we need a solution for activity rollups # before sending notifications on bulk changes if not is_bulk: if acting_user: GroupSubscription.objects.subscribe( user=acting_user, group=group, reason=GroupSubscriptionReason.status_change, ) activity.send_notification() if 'assignedTo' in result: if result['assignedTo']: for group in group_list: GroupAssignee.objects.assign(group, result['assignedTo'], acting_user) if 'isSubscribed' not in result or result['assignedTo'] != request.user: GroupSubscription.objects.subscribe( group=group, user=result['assignedTo'], reason=GroupSubscriptionReason.assigned, ) result['assignedTo'] = serialize(result['assignedTo']) else: for group in group_list: GroupAssignee.objects.deassign(group, acting_user) if result.get('hasSeen') and project.member_set.filter(user=acting_user).exists(): for group in group_list: instance, created = create_or_update( GroupSeen, group=group, user=acting_user, project=group.project, values={ 'last_seen': timezone.now(), } ) elif result.get('hasSeen') is False: GroupSeen.objects.filter( group__in=group_ids, user=acting_user, ).delete() if result.get('isBookmarked'): for group in group_list: GroupBookmark.objects.get_or_create( project=project, group=group, user=acting_user, ) GroupSubscription.objects.subscribe( user=acting_user, group=group, reason=GroupSubscriptionReason.bookmark, ) elif result.get('isBookmarked') is False: GroupBookmark.objects.filter( group__in=group_ids, user=acting_user, ).delete() # TODO(dcramer): we could make these more efficient by first # querying for rich rows are present (if N > 2), flipping the flag # on those rows, and then creating the missing rows if result.get('isSubscribed') in (True, False): is_subscribed = result['isSubscribed'] for group in group_list: # NOTE: Subscribing without an initiating event (assignment, # commenting, etc.) clears out the previous subscription reason # to avoid showing confusing messaging as a result of this # action. It'd be jarring to go directly from "you are not # subscribed" to "you were subscribed due since you were # assigned" just by clicking the "subscribe" button (and you # may no longer be assigned to the issue anyway.) GroupSubscription.objects.create_or_update( user=acting_user, group=group, project=project, values={ 'is_active': is_subscribed, 'reason': GroupSubscriptionReason.unknown, }, ) result['subscriptionDetails'] = { 'reason': SUBSCRIPTION_REASON_MAP.get( GroupSubscriptionReason.unknown, 'unknown', ), } if result.get('isPublic'): queryset.update(is_public=True) for group in group_list: if group.is_public: continue group.is_public = True Activity.objects.create( project=group.project, group=group, type=Activity.SET_PUBLIC, user=acting_user, ) elif result.get('isPublic') is False: queryset.update(is_public=False) for group in group_list: if not group.is_public: continue group.is_public = False Activity.objects.create( project=group.project, group=group, type=Activity.SET_PRIVATE, user=acting_user, ) # XXX(dcramer): this feels a bit shady like it should be its own # endpoint if result.get('merge') and len(group_list) > 1: primary_group = sorted(group_list, key=lambda x: -x.times_seen)[0] children = [] transaction_id = uuid4().hex for group in group_list: if group == primary_group: continue children.append(group) group.update(status=GroupStatus.PENDING_MERGE) merge_group.delay( from_object_id=group.id, to_object_id=primary_group.id, transaction_id=transaction_id, ) Activity.objects.create( project=primary_group.project, group=primary_group, type=Activity.MERGE, user=acting_user, data={ 'issues': [{'id': c.id} for c in children], }, ) result['merge'] = { 'parent': six.text_type(primary_group.id), 'children': [six.text_type(g.id) for g in children], } return Response(result)