def _generate_thumbnail_name(instance: Union[Layer, Map]) -> Optional[str]: """ Method returning file name for the thumbnail. If provided instance is a Map, and doesn't have any defined layers, None is returned. :param instance: instance of Layer or Map models :return: file name for the thumbnail :raises ThumbnailError: if provided instance is neither an instance of the Map nor of the Layer """ if isinstance(instance, Layer): file_name = f"layer-{instance.uuid}-thumb.png" elif isinstance(instance, Map): # if a Map is empty - nothing to do here if not instance.layers: logger.debug(f"Thumbnail generation skipped - Map {instance.title} has no defined layers") return None file_name = f"map-{instance.uuid}-thumb.png" else: raise ThumbnailError( "Thumbnail generation didn't recognize the provided instance: it's neither a Layer nor a Map." ) return file_name
def _generate_thumbnail_name( instance: Union[Dataset, Map, Document, GeoApp]) -> Optional[str]: """ Method returning file name for the thumbnail. If provided instance is a Map, and doesn't have any defined datasets, None is returned. :param instance: instance of Dataset or Map models :return: file name for the thumbnail :raises ThumbnailError: if provided instance is neither an instance of the Map nor of the Dataset """ if isinstance(instance, Dataset): file_name = f"dataset-{instance.uuid}-thumb.png" elif isinstance(instance, Map): # if a Map is empty - nothing to do here if not instance.maplayers: logger.debug( f"Thumbnail generation skipped - Map {instance.title} has no defined datasets" ) return None file_name = f"map-{instance.uuid}-thumb.png" elif isinstance(instance, Document): file_name = f"document-{instance.uuid}-thumb.png" elif isinstance(instance, GeoApp): file_name = f"geoapp-{instance.uuid}-thumb.png" else: raise ThumbnailError( "Thumbnail generation didn't recognize the provided instance.") return file_name
def clean_bbox(bbox, target_crs): # make sure BBOX is provided with the CRS in a correct format source_crs = bbox[-1] srid_regex = re.match(r"EPSG:\d+", source_crs) if not srid_regex: logger.error(f"Thumbnail bbox is in a wrong format: {bbox}") raise ThumbnailError("Wrong BBOX format") # for the EPSG:3857 (default thumb's CRS) - make sure received BBOX can be transformed to the target CRS; # if it can't be (original coords are outside of the area of use of EPSG:3857), thumbnail generation with # the provided bbox is impossible. if target_crs == 'EPSG:3857' and bbox[-1].upper() != 'EPSG:3857': bbox = crop_to_3857_area_of_use(bbox) bbox = transform_bbox(bbox, target_crs=target_crs) return bbox
def fetch(self, bbox: typing.List, zoom: int = None, *args, **kwargs): """ The function fetching tiles from a Slippy Map provider, composing them into a single image, and cropping it to match the given BBOX. Retrieval of each tile is repeated self.max_retries times, waiting self.retry_delay seconds between consecutive requests. :param bbox: bounding box of the background image, dataset compliant format: [west, east, south, north, CRS] :param zoom: zoom with which to retrieve Slippy Map's tiles (by default, it's calculated based on width, height) :return: None if the CRS is different from self.tiles_crs, or background Image """ if not self.url: logger.error("Thumbnail background requires url to be configured.") raise ThumbnailError("Tiled background improperly configured.") if bbox[-1].lower() != self.crs.lower(): # background service is not available the requested CRS CRS logger.debug( f"Thumbnail background generation skipped. " f"Clashing CRSs: requested {bbox[-1]}, supported {self.crs}") return bbox = [float(coord) for coord in bbox[0:4]] # check if BBOX fits within the EPSG:3857 map, if not - return an empty background if bbox[2] > self._epsg3857_max_y or bbox[3] < -self._epsg3857_max_y: return Image.new("RGB", (self.thumbnail_width, self.thumbnail_height), (250, 250, 250)) bbox4326 = self.bbox3857to4326(*bbox) # change bbox from dataset (left, right, bottom, top) to mercantile (left, bottom, right, top) self._mercantile_bbox = [ bbox4326[0], bbox4326[2], bbox4326[1], bbox4326[3] ] # calculate zoom level if zoom is None: zoom = self.calculate_zoom() else: zoom = int(zoom) top_left_tile = mercantile.tile(bbox4326[0], bbox4326[3], zoom) bottom_right_tile = mercantile.tile(bbox4326[1], bbox4326[2], zoom) # rescaling factors - indicators of how west and east BBOX boundaries are offset in respect to the world's map; # east and west boundaries may exceed the maximum coordinate of the world in EPSG:3857. In such case additinal # number of tiles need to be fetched to compose the image and the boundary tiles' coordinates need to be # rescaled to ensure the proper image cropping. epsg3857_world_width = 2 * self._epsg3857_max_x west_rescaling_factor = 0 if abs(bbox[0]) > self._epsg3857_max_x: west_rescaling_factor = ceil( (abs(bbox[0]) - self._epsg3857_max_x) / epsg3857_world_width) * copysign(1, bbox[0]) east_rescaling_factor = 0 if abs(bbox[1]) > self._epsg3857_max_x: east_rescaling_factor = ceil( (abs(bbox[1]) - self._epsg3857_max_x) / epsg3857_world_width) * copysign(1, bbox[1]) map_row_tiles = 2**zoom - 1 # number of tiles in the Map's row for a certain zoom level map_worlds = int(east_rescaling_factor - west_rescaling_factor) # number maps in an image worlds_between = map_worlds - 1 # number of full maps in an image if top_left_tile.x > bottom_right_tile.x or bbox[1] - bbox[ 0] > epsg3857_world_width or map_worlds > 0: # BBOX crosses Slippy Map's border if worlds_between > 0: tiles_rows = (list(range(top_left_tile.x, map_row_tiles + 1)) + worlds_between * list(range(map_row_tiles + 1)) + list(range(bottom_right_tile.x + 1))) else: tiles_rows = list(range(top_left_tile.x, map_row_tiles + 1)) + list( range(bottom_right_tile.x + 1)) else: # BBOx is contained by the Slippy Map if worlds_between > 0: tiles_rows = list( range(top_left_tile.x, bottom_right_tile.x + 1)) + worlds_between * list(range(map_row_tiles + 1)) else: tiles_rows = list( range(top_left_tile.x, bottom_right_tile.x + 1)) tiles_cols = list(range(top_left_tile.y, bottom_right_tile.y + 1)) # if latitude boundaries extend world's height - add background's height, and set constant Y offset for tiles additional_height = 0 fixed_top_offset = 0 fixed_bottom_offset = 0 north_extension3857 = max(0, bbox[3] - self._epsg3857_max_y) south_extension3857 = abs(min(0, bbox[2] + self._epsg3857_max_y)) extension3857 = north_extension3857 + south_extension3857 if extension3857: # get single tile's height in ESPG:3857 tile_bounds = mercantile.bounds(tiles_rows[0], tiles_cols[0], zoom) _, south = self.point4326to3857(getattr(tile_bounds, "west"), getattr(tile_bounds, "south")) _, north = self.point4326to3857(getattr(tile_bounds, "west"), getattr(tile_bounds, "north")) tile_hight3857 = north - south additional_height = round( self.tile_size * extension3857 / tile_hight3857) # based on linear proportion if north_extension3857: fixed_top_offset = round(self.tile_size * north_extension3857 / tile_hight3857) if south_extension3857: fixed_bottom_offset = round( self.tile_size * south_extension3857 / tile_hight3857) background = Image.new( "RGB", (len(tiles_rows) * self.tile_size, len(tiles_cols) * self.tile_size + additional_height), (250, 250, 250), ) for offset_x, x in enumerate(tiles_rows): for offset_y, y in enumerate(tiles_cols): if self.tms: y = (2**zoom) - y - 1 imgurl = self.url.format(x=x, y=y, z=zoom) im = None for retries in range(self.max_retries): try: resp, content = http_client.request(imgurl) if resp.status_code > 400: retries = self.max_retries - 1 raise Exception(f"{strip_tags(content)}") im = BytesIO(content) Image.open( im).verify() # verify that it is, in fact an image break except Exception as e: logger.error( f"Thumbnail background fetching from {imgurl} failed {retries} time(s) with: {e}" ) if retries + 1 == self.max_retries: raise e time.sleep(self.retry_delay) continue if im: image = Image.open( im ) # "re-open" the file (required after running verify method) # add the fetched tile to the background image, placing it under proper coordinates background.paste( image, (offset_x * self.tile_size, offset_y * self.tile_size + fixed_top_offset)) # get BBOX of the tiles top_left_bounds = mercantile.bounds(top_left_tile) bottom_right_bounds = mercantile.bounds(bottom_right_tile) tiles_bbox3857 = self.bbox4326to3857( getattr(top_left_bounds, "west"), getattr(bottom_right_bounds, "east"), getattr(bottom_right_bounds, "south"), getattr(top_left_bounds, "north"), ) # rescale tiles' boundaries - if space covered by the input BBOX extends the width of the world, # (e.g. two "worlds" are present on the map), translation between tiles' BBOX and image's pixel requires # additional rescaling, for tiles' BBOX coordinates to match input BBOX coordinates west_coord = tiles_bbox3857[ 0] + west_rescaling_factor * epsg3857_world_width east_coord = tiles_bbox3857[ 1] + east_rescaling_factor * epsg3857_world_width # prepare translating function from received BBOX to pixel values of the background image src_quad = (0, fixed_top_offset, background.size[0], background.size[1] - fixed_bottom_offset) to_src_px = utils.make_bbox_to_pixels_transf( [west_coord, tiles_bbox3857[2], east_coord, tiles_bbox3857[3]], src_quad) # translate received BBOX to pixel values minx, miny = to_src_px(bbox[0], bbox[2]) maxx, maxy = to_src_px(bbox[1], bbox[3]) # max and min function for Y axis were introduced to mitigate rounding errors crop_box = ( ceil(minx), max(ceil(maxy) + fixed_top_offset, 0), floor(maxx), min(floor(miny) + fixed_top_offset, background.size[1]), ) if not all([ 0 <= crop_x <= background.size[0] for crop_x in [crop_box[0], crop_box[2]] ]): raise ThumbnailError( f"Tiled background cropping error. Boundaries outside of the image: {crop_box}" ) # crop background image to the desired bbox and resize it background = background.crop(box=crop_box) background = background.resize( (self.thumbnail_width, self.thumbnail_height)) if sum(background.convert("L").getextrema()) in (0, 2): # either all black or all white logger.error("Thumbnail background outside the allowed area.") raise ThumbnailError( "Thumbnail background outside the allowed area.") return background
def create_thumbnail( instance: Union[Layer, Map], wms_version: str = settings.OGC_SERVER["default"].get("WMS_VERSION", "1.1.1"), bbox: Optional[Union[List, Tuple]] = None, forced_crs: Optional[str] = None, styles: Optional[List] = None, overwrite: bool = False, background_zoom: Optional[int] = None, ) -> None: """ Function generating and saving a thumbnail of the given instance (Layer or Map), which is composed of outcomes of WMS GetMap queries to the instance's layers providers, and an outcome of querying background provider for thumbnail's background (by default Slippy Map provider). :param instance: instance of Layer or Map models :param wms_version: WMS version of the query :param bbox: bounding box of the thumbnail in format: (west, east, south, north, CRS), where CRS is in format "EPSG:XXXX" :param forced_crs: CRS which should be used to fetch data from WMS services in format "EPSG:XXXX". By default all data is translated and retrieved in EPSG:3857, since this enables background fetching from Slippy Maps providers. Forcing another CRS can cause skipping background generation in the thumbnail :param styles: styles, which OGC server should use for rendering an image :param overwrite: overwrite existing thumbnail :param background_zoom: zoom of the XYZ Slippy Map used to retrieve background image, if Slippy Map is used as background """ instance.refresh_from_db() default_thumbnail_name = _generate_thumbnail_name(instance) mime_type = "image/png" width = settings.THUMBNAIL_SIZE["width"] height = settings.THUMBNAIL_SIZE["height"] if default_thumbnail_name is None: # instance is Map and has no layers defined utils.assign_missing_thumbnail(instance) return # handle custom, uploaded thumbnails, which may have different extensions from the default thumbnail thumbnail_exists = False if instance.thumbnail_url and instance.thumbnail_url != settings.MISSING_THUMBNAIL: thumbnail_exists = thumb_exists(instance.thumbnail_url.rsplit('/')[-1]) if (thumbnail_exists or thumb_exists(default_thumbnail_name)) and not overwrite: logger.debug(f"Thumbnail for {instance.name} already exists. Skipping thumbnail generation.") return # --- determine target CRS and bbox --- target_crs = forced_crs.upper() if forced_crs is not None else "EPSG:3857" compute_bbox_from_layers = False if bbox: # make sure BBOX is provided with the CRS in a correct format source_crs = bbox[-1] srid_regex = re.match(r"EPSG:\d+", source_crs) if not srid_regex: logger.error(f"Thumbnail bbox is in a wrong format: {bbox}") raise ThumbnailError("Wrong BBOX format") # for the EPSG:3857 (default thumb's CRS) - make sure received BBOX can be transformed to the target CRS; # if it can't be (original coords are outside of the area of use of EPSG:3857), thumbnail generation with # the provided bbox is impossible. if target_crs == 'EPSG:3857' and bbox[-1].upper() != 'EPSG:3857': bbox = utils.crop_to_3857_area_of_use(bbox) bbox = utils.transform_bbox(bbox, target_crs=target_crs) else: compute_bbox_from_layers = True # --- define layer locations --- locations, layers_bbox = _layers_locations(instance, compute_bbox=compute_bbox_from_layers, target_crs=target_crs) if compute_bbox_from_layers: if not layers_bbox: raise ThumbnailError(f"Thumbnail generation couldn't determine a BBOX for: {instance}.") else: bbox = layers_bbox # --- expand the BBOX to match the set thumbnail's ratio (prevent thumbnail's distortions) --- bbox = utils.expand_bbox_to_ratio(bbox) # --- add default style --- if not styles and hasattr(instance, "default_style"): if instance.default_style: styles = [instance.default_style.name] # --- fetch WMS layers --- partial_thumbs = [] for ogc_server, layers in locations: try: partial_thumbs.append(utils.get_map( ogc_server, layers, wms_version=wms_version, bbox=bbox, mime_type=mime_type, styles=styles, width=width, height=height, )) except Exception as e: logger.error(f"Exception occurred while fetching partial thumbnail for {instance.name}.") logger.exception(e) if not partial_thumbs: utils.assign_missing_thumbnail(instance) raise ThumbnailError("Thumbnail generation failed - no image retrieved from WMS services.") # --- merge retrieved WMS images --- merged_partial_thumbs = Image.new("RGBA", (width, height), (255, 255, 255, 0)) for image in partial_thumbs: if image: content = BytesIO(image) try: img = Image.open(content) img.verify() # verify that it is, in fact an image img = Image.open(BytesIO(image)) # "re-open" the file (required after running verify method) merged_partial_thumbs.paste(img, mask=img.convert('RGBA')) except UnidentifiedImageError as e: logger.error(f"Thumbnail generation. Error occurred while fetching layer image: {image}") logger.exception(e) # --- fetch background image --- try: BackgroundGenerator = import_string(settings.THUMBNAIL_BACKGROUND["class"]) background = BackgroundGenerator(width, height).fetch(bbox, background_zoom) except Exception as e: logger.error(f"Thumbnail generation. Error occurred while fetching background image: {e}") logger.exception(e) background = None # --- overlay image with background --- thumbnail = Image.new("RGB", (width, height), (250, 250, 250)) if background is not None: thumbnail.paste(background, (0, 0)) thumbnail.paste(merged_partial_thumbs, (0, 0), merged_partial_thumbs) # convert image to the format required by save_thumbnail with BytesIO() as output: thumbnail.save(output, format="PNG") content = output.getvalue() # save thumbnail instance.save_thumbnail(default_thumbnail_name, image=content)
class BaseApiTests(APITestCase): fixtures = [ 'initial_data.json', 'group_test_data.json', 'default_oauth_apps.json', "test_thesaurus.json" ] def setUp(self): create_models(b'document') create_models(b'map') create_models(b'dataset') def test_gropus_list(self): """ Ensure we can access the gropus list. """ url = reverse('group-profiles-list') # Unauhtorized response = self.client.get(url, format='json') self.assertEqual(response.status_code, 403) # Auhtorized self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) logger.debug(response.data) self.assertEqual(response.data['total'], 2) self.assertEqual(len(response.data['group_profiles']), 2) url = reverse('group-profiles-detail', kwargs={'pk': 1}) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) logger.debug(response.data) self.assertEqual(response.data['group_profile']['title'], 'Registered Members') self.assertEqual(response.data['group_profile']['description'], 'Registered Members') self.assertEqual(response.data['group_profile']['access'], 'private') self.assertEqual(response.data['group_profile']['group']['name'], response.data['group_profile']['slug']) def test_users_list(self): """ Ensure we can access the users list. """ url = reverse('users-list') # Anonymous response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) logger.debug(response.data) self.assertEqual(response.data['total'], 0) self.assertEqual(len(response.data['users']), 0) # Auhtorized self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) logger.debug(response.data) self.assertEqual(response.data['total'], 10) self.assertEqual(len(response.data['users']), 10) # response has link to the response self.assertTrue('link' in response.data['users'][0].keys()) url = reverse('users-detail', kwargs={'pk': 1}) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) logger.debug(response.data) self.assertEqual(response.data['user']['username'], 'admin') self.assertIsNotNone(response.data['user']['avatar']) # anonymous users are not in contributors group url = reverse('users-detail', kwargs={'pk': -1}) response = self.client.get(url, format='json') self.assertNotIn('add_resource', response.data['user']['perms']) # Bobby self.assertTrue(self.client.login(username='******', password='******')) # Bobby cannot access other users' details response = self.client.get(url, format='json') self.assertEqual(response.status_code, 404) # Bobby can see himself in the list url = reverse('users-list') self.assertEqual(len(response.data), 1) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) logger.debug(response.data) self.assertEqual(response.data['total'], 1) self.assertEqual(len(response.data['users']), 1) # Bobby can access its own details bobby = get_user_model().objects.filter(username='******').get() url = reverse('users-detail', kwargs={'pk': bobby.id}) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) logger.debug(response.data) self.assertEqual(response.data['user']['username'], 'bobby') self.assertIsNotNone(response.data['user']['avatar']) # default contributor group_perm is returned in perms self.assertIn('add_resource', response.data['user']['perms']) def test_register_users(self): """ Ensure users are created with default groups. """ url = reverse('users-list') user_data = { 'username': '******', } response = self.client.post(url, data=user_data, format='json') self.assertEqual(response.status_code, 201) # default contributor group_perm is returned in perms self.assertIn('add_resource', response.data['user']['perms']) def test_base_resources(self): """ Ensure we can access the Resource Base list. """ url = reverse('base-resources-list') # Anonymous response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 26) # Pagination self.assertEqual(len(response.data['resources']), 10) logger.debug(response.data) # Remove public permissions to Layers from geonode.layers.utils import set_datasets_permissions set_datasets_permissions( "read", # permissions_name None, # resources_names == None (all layers) [get_anonymous_user()], # users_usernames None, # groups_names True, # delete_flag ) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 26) # Pagination self.assertEqual(len(response.data['resources']), 10) # Admin self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 26) # response has link to the response self.assertTrue('link' in response.data['resources'][0].keys()) # Pagination self.assertEqual(len(response.data['resources']), 10) logger.debug(response.data) # Bobby self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 26) # Pagination self.assertEqual(len(response.data['resources']), 10) logger.debug(response.data) # Norman self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 26) # Pagination self.assertEqual(len(response.data['resources']), 10) logger.debug(response.data) # Pagination # Admin self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(f"{url}?page_size=17", format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 26) # Pagination self.assertEqual(len(response.data['resources']), 17) # Check user permissions resource = ResourceBase.objects.filter(owner__username='******').first() # Admin response = self.client.get(f"{url}/{resource.id}/", format='json') self.assertEqual(response.data['resource']['state'], enumerations.STATE_PROCESSED) self.assertEqual(response.data['resource']['sourcetype'], enumerations.SOURCE_TYPE_LOCAL) self.assertTrue('change_resourcebase' in list(response.data['resource']['perms'])) # Annonymous self.assertIsNone(self.client.logout()) response = self.client.get(f"{url}/{resource.id}/", format='json') self.assertFalse('change_resourcebase' in list(response.data['resource']['perms'])) # user owner self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(f"{url}/{resource.id}/", format='json') self.assertTrue('change_resourcebase' in list(response.data['resource']['perms'])) # user not owner and not assigned self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(f"{url}/{resource.id}/", format='json') self.assertFalse('change_resourcebase' in list(response.data['resource']['perms'])) def test_delete_user_with_resource(self): owner, created = get_user_model().objects.get_or_create(username='******') Dataset( title='Test Remove User', abstract='abstract', name='Test Remove User', alternate='Test Remove User', uuid=str(uuid4()), owner=owner, subtype='raster', category=TopicCategory.objects.get(identifier='elevation') ).save() # Delete user and check if default user is updated owner.delete() self.assertEqual( ResourceBase.objects.get(title='Test Remove User').owner, get_user_model().objects.get(username='******') ) def test_search_resources(self): """ Ensure we can search across the Resource Base list. """ url = reverse('base-resources-list') # Admin self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get( f"{url}?search=ca&search_fields=title&search_fields=abstract", format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 1) # Pagination self.assertEqual(len(response.data['resources']), 1) def test_filter_resources(self): """ Ensure we can filter across the Resource Base list. """ url = reverse('base-resources-list') # Admin self.assertTrue(self.client.login(username='******', password='******')) # Filter by owner == bobby response = self.client.get(f"{url}?filter{{owner.username}}=bobby", format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 3) # Pagination self.assertEqual(len(response.data['resources']), 3) # Filter by resource_type == document response = self.client.get(f"{url}?filter{{resource_type}}=document", format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 9) # Pagination self.assertEqual(len(response.data['resources']), 9) # Filter by resource_type == layer and title like 'common morx' response = self.client.get( f"{url}?filter{{resource_type}}=dataset&filter{{title.icontains}}=common morx", format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 1) # Pagination self.assertEqual(len(response.data['resources']), 1) # Filter by Keywords response = self.client.get( f"{url}?filter{{keywords.name}}=here", format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 1) # Pagination self.assertEqual(len(response.data['resources']), 1) # Filter by Metadata Regions response = self.client.get( f"{url}?filter{{regions.name.icontains}}=Italy", format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 0) # Pagination self.assertEqual(len(response.data['resources']), 0) # Filter by Metadata Categories response = self.client.get( f"{url}?filter{{category.identifier}}=elevation", format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 6) # Pagination self.assertEqual(len(response.data['resources']), 6) # Extent Filter response = self.client.get(f"{url}?page_size=26&extent=-180,-90,180,90", format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 26) # Pagination self.assertEqual(len(response.data['resources']), 26) response = self.client.get(f"{url}?page_size=26&extent=0,0,100,100", format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 26) # Pagination self.assertEqual(len(response.data['resources']), 26) response = self.client.get(f"{url}?page_size=26&extent=-10,-10,-1,-1", format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 12) # Pagination self.assertEqual(len(response.data['resources']), 12) # Extent Filter: Crossing Dateline extent = "-180.0000,56.9689,-162.5977,70.7435,155.9180,56.9689,180.0000,70.7435" response = self.client.get( f"{url}?page_size=26&extent={extent}", format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 12) # Pagination self.assertEqual(len(response.data['resources']), 12) def test_sort_resources(self): """ Ensure we can sort the Resource Base list. """ url = reverse('base-resources-list') # Admin self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get( f"{url}?sort[]=title", format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 26) # Pagination self.assertEqual(len(response.data['resources']), 10) resource_titles = [] for _r in response.data['resources']: resource_titles.append(_r['title']) sorted_resource_titles = sorted(resource_titles.copy()) self.assertEqual(resource_titles, sorted_resource_titles) response = self.client.get( f"{url}?sort[]=-title", format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 26) # Pagination self.assertEqual(len(response.data['resources']), 10) resource_titles = [] for _r in response.data['resources']: resource_titles.append(_r['title']) reversed_resource_titles = sorted(resource_titles.copy()) self.assertNotEqual(resource_titles, reversed_resource_titles) def test_perms_resources(self): """ Ensure we can Get & Set Permissions across the Resource Base list. """ url = reverse('base-resources-list') # Admin self.assertTrue(self.client.login(username='******', password='******')) resource = ResourceBase.objects.filter(owner__username='******').first() set_perms_url = urljoin(f"{reverse('base-resources-detail', kwargs={'pk': resource.pk})}/", 'set_perms/') get_perms_url = urljoin(f"{reverse('base-resources-detail', kwargs={'pk': resource.pk})}/", 'get_perms/') url = reverse('base-resources-detail', kwargs={'pk': resource.pk}) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(int(response.data['resource']['pk']), int(resource.pk)) response = self.client.get(get_perms_url, format='json') self.assertEqual(response.status_code, 200) resource_perm_spec = response.data self.assertTrue('bobby' in resource_perm_spec['users']) self.assertFalse('norman' in resource_perm_spec['users']) # Add perms to Norman resource_perm_spec['users']['norman'] = resource_perm_spec['users']['bobby'] response = self.client.put(set_perms_url, data=resource_perm_spec, format='json') self.assertEqual(response.status_code, 200) response = self.client.get(get_perms_url, format='json') self.assertEqual(response.status_code, 200) resource_perm_spec = response.data self.assertTrue('norman' in resource_perm_spec['users']) # Remove perms to Norman resource_perm_spec['users']['norman'] = [] response = self.client.put(set_perms_url, data=resource_perm_spec, format='json') self.assertEqual(response.status_code, 200) response = self.client.get(get_perms_url, format='json') self.assertEqual(response.status_code, 200) resource_perm_spec = response.data self.assertFalse('norman' in resource_perm_spec['users']) # Ensure get_perms and set_perms are done by users with correct permissions. # logout admin user self.assertIsNone(self.client.logout()) # get perms response = self.client.get(get_perms_url, format='json') self.assertEqual(response.status_code, 403) # set perms response = self.client.put(set_perms_url, data=resource_perm_spec, format='json') self.assertEqual(response.status_code, 403) # login resourse owner # get perms self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(get_perms_url, format='json') self.assertEqual(response.status_code, 200) # set perms response = self.client.put(set_perms_url, data=resource_perm_spec, format='json') self.assertEqual(response.status_code, 200) def test_featured_and_published_resources(self): """ Ensure we can Get & Set Permissions across the Resource Base list. """ url = reverse('base-resources-list') # Admin self.assertTrue(self.client.login(username='******', password='******')) resources = ResourceBase.objects.filter(owner__username='******', metadata_only=False) url = urljoin(f"{reverse('base-resources-list')}/", 'featured/') response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], 0) # Pagination self.assertEqual(len(response.data['resources']), 0) resources.filter(resource_type='map').update(featured=True) url = urljoin(f"{reverse('base-resources-list')}/", 'featured/') response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) self.assertEqual(response.data['total'], resources.filter(resource_type='map').count()) # Pagination self.assertEqual(len(response.data['resources']), resources.filter(resource_type='map').count()) def test_resource_types(self): """ Ensure we can Get & Set Permissions across the Resource Base list. """ url = urljoin(f"{reverse('base-resources-list')}/", 'resource_types/') response = self.client.get(url, format='json') r_type_names = [item['name'] for item in response.data['resource_types']] self.assertEqual(response.status_code, 200) self.assertTrue('resource_types' in response.data) self.assertTrue('dataset' in r_type_names) self.assertTrue('map' in r_type_names) self.assertTrue('document' in r_type_names) self.assertFalse('service' in r_type_names) def test_get_favorites(self): """ Ensure we get user's favorite resources. """ dataset = Dataset.objects.first() url = urljoin(f"{reverse('base-resources-list')}/", 'favorites/') # Anonymous response = self.client.get(url, format='json') self.assertEqual(response.status_code, 403) # Authenticated user bobby = get_user_model().objects.get(username='******') self.assertTrue(self.client.login(username='******', password='******')) favorite = Favorite.objects.create_favorite(dataset, bobby) response = self.client.get(url, format='json') self.assertEqual(response.data['total'], 1) self.assertEqual(response.status_code, 200) # clean up favorite.delete() def test_create_and_delete_favorites(self): """ Ensure we can add and remove resources to user's favorite. """ dataset = get_resources_with_perms(get_user_model().objects.get(pk=-1)).first() url = urljoin(f"{reverse('base-resources-list')}/", f"{dataset.pk}/favorite/") # Anonymous response = self.client.post(url, format='json') self.assertEqual(response.status_code, 403) # Authenticated user self.assertTrue(self.client.login(username='******', password='******')) response = self.client.post(url, format="json") self.assertEqual(response.data["message"], "Successfuly added resource to favorites") self.assertEqual(response.status_code, 201) # add resource to favorite again response = self.client.post(url, format="json") self.assertEqual(response.data["message"], "Resource is already in favorites") self.assertEqual(response.status_code, 400) # remove resource from favorites response = self.client.delete(url, format="json") self.assertEqual(response.data["message"], "Successfuly removed resource from favorites") self.assertEqual(response.status_code, 200) # remove resource to favorite again response = self.client.delete(url, format="json") self.assertEqual(response.data["message"], "Resource not in favorites") self.assertEqual(response.status_code, 404) def test_search_resources_with_favorite_true_and_no_favorite_should_return_0(self): """ Ensure we can search across the Resource Base list. """ url = reverse('base-resources-list') # Admin self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get( f"{url}?favorite=true", format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) # No favorite are saved, so the total should be 0 self.assertEqual(response.data['total'], 0) self.assertEqual(len(response.data['resources']), 0) def test_search_resources_with_favorite_true_and_favorite_should_return_1(self): """ Ensure we can search across the Resource Base list. """ url = reverse('base-resources-list') # Admin admin = get_user_model().objects.get(username='******') dataset = Dataset.objects.first() Favorite.objects.create_favorite(dataset, admin) self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get( f"{url}?favorite=true", format='json') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 5) # 1 favorite is saved, so the total should be 1 self.assertEqual(response.data['total'], 1) self.assertEqual(len(response.data['resources']), 1) @patch('PIL.Image.open', return_value=test_image) def test_thumbnail_urls(self, img): """ Ensure the thumbnail url reflects the current active Thumb on the resource. """ # Admin self.assertTrue(self.client.login(username='******', password='******')) resource = ResourceBase.objects.filter(owner__username='******').first() url = reverse('base-resources-detail', kwargs={'pk': resource.pk}) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(int(response.data['resource']['pk']), int(resource.pk)) thumbnail_url = response.data['resource']['thumbnail_url'] self.assertIsNone(thumbnail_url) f = BytesIO(test_image.tobytes()) f.name = 'test_image.jpeg' curated_thumbnail = CuratedThumbnail.objects.create(resource=resource, img=File(f)) url = reverse('base-resources-detail', kwargs={'pk': resource.pk}) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(int(response.data['resource']['pk']), int(resource.pk)) thumbnail_url = response.data['resource']['thumbnail_url'] self.assertTrue(curated_thumbnail.thumbnail_url in thumbnail_url) def test_embed_urls(self): """ Ensure the embed urls reflect the concrete instance ones. """ # Admin self.assertTrue(self.client.login(username='******', password='******')) resources = ResourceBase.objects.all() for resource in resources: url = reverse('base-resources-detail', kwargs={'pk': resource.pk}) response = self.client.get(url, format='json') if resource.title.endswith('metadata true'): self.assertEqual(response.status_code, 404) else: self.assertEqual(response.status_code, 200) self.assertEqual(int(response.data['resource']['pk']), int(resource.pk)) embed_url = response.data['resource']['embed_url'] self.assertIsNotNone(embed_url) instance = resource.get_real_instance() if hasattr(instance, 'embed_url'): if instance.embed_url != NotImplemented: self.assertEqual(build_absolute_uri(instance.embed_url), embed_url) else: self.assertEqual("", embed_url) def test_owners_list(self): """ Ensure we can access the list of owners. """ url = reverse('owners-list') # Anonymous response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['total'], 8) # Admin self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['total'], 8) response = self.client.get(f"{url}?type=geoapp", format='json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['total'], 0) response = self.client.get(f"{url}?type=dataset&title__icontains=CA", format='json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['total'], 1) # response has link to the response self.assertTrue('link' in response.data['owners'][0].keys()) # Authenticated user self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['total'], 8) def test_categories_list(self): """ Ensure we can access the list of categories. """ url = reverse('categories-list') # Anonymous response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['total'], TopicCategory.objects.count()) # Admin self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['total'], TopicCategory.objects.count()) # response has link to the response self.assertTrue('link' in response.data['categories'][0].keys()) # Authenticated user self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['total'], TopicCategory.objects.count()) def test_regions_list(self): """ Ensure we can access the list of regions. """ url = reverse('regions-list') # Anonymous response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['total'], Region.objects.count()) # Admin self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['total'], Region.objects.count()) # response has link to the response self.assertTrue('link' in response.data['regions'][0].keys()) # Authenticated user self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['total'], Region.objects.count()) def test_keywords_list(self): """ Ensure we can access the list of keywords. """ url = reverse('keywords-list') # Anonymous response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['total'], HierarchicalKeyword.objects.count()) # Admin self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['total'], HierarchicalKeyword.objects.count()) # response has link to the response self.assertTrue('link' in response.data['keywords'][0].keys()) # Authenticated user self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['total'], HierarchicalKeyword.objects.count()) def test_tkeywords_list(self): """ Ensure we can access the list of thasaurus keywords. """ url = reverse('tkeywords-list') # Anonymous response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['total'], ThesaurusKeyword.objects.count()) # Admin self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['total'], ThesaurusKeyword.objects.count()) # response has link to the response self.assertTrue('link' in response.data['tkeywords'][0].keys()) # Authenticated user self.assertTrue(self.client.login(username='******', password='******')) response = self.client.get(url, format='json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['total'], ThesaurusKeyword.objects.count()) def test_set_thumbnail_from_bbox_from_Anonymous_user_raise_permission_error(self): """ Given a request with Anonymous user, should raise an authentication error. """ dataset_id = sys.maxsize url = reverse('base-resources-set-thumb-from-bbox', args=[dataset_id]) # Anonymous expected = { "detail": "Authentication credentials were not provided." } response = self.client.post(url, format='json') self.assertEqual(response.status_code, 403) self.assertEqual(expected, response.json()) @patch("geonode.base.api.views.create_thumbnail") def test_set_thumbnail_from_bbox_from_logged_user_for_existing_dataset(self, mock_create_thumbnail): """ Given a logged User and an existing dataset, should create the expected thumbnail url. """ mock_create_thumbnail.return_value = "http://*****:*****@patch("geonode.base.api.views.create_thumbnail", side_effect=ThumbnailError('Some exception during thumb creation')) def test_set_thumbnail_from_bbox_from_logged_user_for_existing_dataset_raise_exp(self, mock_exp): """ Given a logged User and an existing dataset, should raise a ThumbnailException. """ # Admin self.client.login(username="******", password="******") dataset_id = Dataset.objects.first().resourcebase_ptr_id url = reverse('base-resources-set-thumb-from-bbox', args=[dataset_id]) payload = { "bbox": [], "srid": "EPSG:3857" } response = self.client.post(url, data=payload, format='json') expected = { "message": "Some exception during thumb creation" } self.assertEqual(response.status_code, 500) self.assertEqual(expected, response.json())
def get_map( ogc_server_location: str, layers: List, bbox: List, wms_version: str = settings.OGC_SERVER["default"].get("WMS_VERSION", "1.1.1"), mime_type: str = "image/png", styles: List = None, width: int = 240, height: int = 200, max_retries: int = 3, retry_delay: int = 1, ): """ Function fetching an image from OGC server. For the requests to the configured OGC backend (ogc_server_settings.LOCATION) the function tries to generate an access_token and attach it to the URL. If access_token is not added ant the request is against Geoserver Basic Authentication is used instead. If image retrieval fails, function retries to fetch the image max_retries times, waiting retry_delay seconds between consecutive requests. :param ogc_server_location: OGC server URL :param layers: layers which should be fetched from the OGC server :param bbox: area's bounding box in format: [west, east, south, north, CRS] :param wms_version: WMS version of the query (default: 1.1.1) :param mime_type: mime type of the returned image :param styles: styles, which OGC server should use for rendering an image :param width: width of the returned image :param height: height of the returned image :param max_retries: maximum number of retries before skipping retrieval :param retry_delay: number of seconds waited between retries :returns: retrieved image """ ogc_server_settings = OGC_Servers_Handler(settings.OGC_SERVER)["default"] if ogc_server_location is not None: thumbnail_url = ogc_server_location else: thumbnail_url = ogc_server_settings.LOCATION if thumbnail_url.startswith(ogc_server_settings.PUBLIC_LOCATION): thumbnail_url = thumbnail_url.replace(ogc_server_settings.PUBLIC_LOCATION, ogc_server_settings.LOCATION) wms_endpoint = "" additional_kwargs = {} if thumbnail_url == ogc_server_settings.LOCATION: # add access token to requests to Geoserver (logic based on the previous implementation) username = ogc_server_settings.credentials.username user = get_user_model().objects.filter(username=username).first() if user: access_token = get_or_create_token(user) if access_token and not access_token.is_expired(): additional_kwargs['access_token'] = access_token.token # add WMS endpoint to requests to Geoserver wms_endpoint = getattr(ogc_server_settings, "WMS_ENDPOINT") or "ows" # prepare authorization for WMS service headers = {} if thumbnail_url.startswith(ogc_server_settings.LOCATION): if "access_token" not in additional_kwargs.keys(): # for the Geoserver backend, use Basic Auth, if access_token is not provided _user, _pwd = ogc_server_settings.credentials encoded_credentials = base64.b64encode(f"{_user}:{_pwd}".encode("UTF-8")).decode("ascii") headers["Authorization"] = f"Basic {encoded_credentials}" else: headers["Authorization"] = f"Bearer {additional_kwargs['access_token']}" wms = WebMapService( f"{thumbnail_url}{wms_endpoint}", version=wms_version, headers=headers) image = None for retry in range(max_retries): try: # fetch data image = wms.getmap( layers=layers, styles=styles, srs=bbox[-1] if bbox else None, bbox=[bbox[0], bbox[2], bbox[1], bbox[3]] if bbox else None, size=(width, height), format=mime_type, transparent=True, timeout=getattr(ogc_server_settings, "TIMEOUT", None), **additional_kwargs, ) # validate response if not image or "ServiceException" in str(image.read()): raise ThumbnailError( f"Fetching partial thumbnail from {thumbnail_url} failed with response: {str(image)}" ) except Exception as e: if retry + 1 >= max_retries: logger.exception(e) return time.sleep(retry_delay) continue else: break return image.read()
def create_thumbnail( instance: Union[Dataset, Map], wms_version: str = settings.OGC_SERVER["default"].get("WMS_VERSION", "1.1.1"), bbox: Optional[Union[List, Tuple]] = None, forced_crs: Optional[str] = None, styles: Optional[List] = None, overwrite: bool = False, background_zoom: Optional[int] = None, ) -> None: """ Function generating and saving a thumbnail of the given instance (Dataset or Map), which is composed of outcomes of WMS GetMap queries to the instance's datasets providers, and an outcome of querying background provider for thumbnail's background (by default Slippy Map provider). :param instance: instance of Dataset or Map models :param wms_version: WMS version of the query :param bbox: bounding box of the thumbnail in format: (west, east, south, north, CRS), where CRS is in format "EPSG:XXXX" :param forced_crs: CRS which should be used to fetch data from WMS services in format "EPSG:XXXX". By default all data is translated and retrieved in EPSG:3857, since this enables background fetching from Slippy Maps providers. Forcing another CRS can cause skipping background generation in the thumbnail :param styles: styles, which OGC server should use for rendering an image :param overwrite: overwrite existing thumbnail :param background_zoom: zoom of the XYZ Slippy Map used to retrieve background image, if Slippy Map is used as background """ instance.refresh_from_db() default_thumbnail_name = _generate_thumbnail_name(instance) mime_type = "image/png" width = settings.THUMBNAIL_SIZE["width"] height = settings.THUMBNAIL_SIZE["height"] if default_thumbnail_name is None: # instance is Map and has no datasets defined utils.assign_missing_thumbnail(instance) return # handle custom, uploaded thumbnails, which may have different extensions from the default thumbnail thumbnail_exists = False if instance.thumbnail_url and instance.thumbnail_url != static(utils.MISSING_THUMB): thumbnail_exists = utils.thumb_exists(instance.thumbnail_url.rsplit('/')[-1]) if (thumbnail_exists or utils.thumb_exists(default_thumbnail_name)) and not overwrite: logger.debug(f"Thumbnail for {instance.name} already exists. Skipping thumbnail generation.") return # --- determine target CRS and bbox --- target_crs = forced_crs.upper() if forced_crs is not None else "EPSG:3857" compute_bbox_from_datasets = False is_map_with_datasets = True if isinstance(instance, Map): is_map_with_datasets = MapLayer.objects.filter(map=instance, local=True).exclude(dataset=None).count() > 0 if bbox: bbox = utils.clean_bbox(bbox, target_crs) elif instance.ll_bbox_polygon: _bbox = BBOXHelper(instance.ll_bbox_polygon.extent) srid = instance.ll_bbox_polygon.srid bbox = [_bbox.xmin, _bbox.xmax, _bbox.ymin, _bbox.ymax, f"EPSG:{srid}"] bbox = utils.clean_bbox(bbox, target_crs) else: compute_bbox_from_datasets = True # --- define dataset locations --- locations, datasets_bbox = _datasets_locations(instance, compute_bbox=compute_bbox_from_datasets, target_crs=target_crs) if compute_bbox_from_datasets and is_map_with_datasets: if not datasets_bbox: raise ThumbnailError(f"Thumbnail generation couldn't determine a BBOX for: {instance}.") else: bbox = datasets_bbox # --- expand the BBOX to match the set thumbnail's ratio (prevent thumbnail's distortions) --- bbox = utils.expand_bbox_to_ratio(bbox) if bbox else None # --- add default style --- if not styles and hasattr(instance, "default_style"): if instance.default_style: styles = [instance.default_style.name] # --- fetch WMS datasets --- partial_thumbs = [] for ogc_server, datasets, _styles in locations: if isinstance(instance, Map) and len(datasets) == len(_styles): styles = _styles try: partial_thumbs.append( utils.get_map( ogc_server, datasets, wms_version=wms_version, bbox=bbox, mime_type=mime_type, styles=styles, width=width, height=height, ) ) except Exception as e: logger.error(f"Exception occurred while fetching partial thumbnail for {instance.title}.") logger.exception(e) if not partial_thumbs and is_map_with_datasets: utils.assign_missing_thumbnail(instance) raise ThumbnailError("Thumbnail generation failed - no image retrieved from WMS services.") # --- merge retrieved WMS images --- merged_partial_thumbs = Image.new("RGBA", (width, height), (255, 255, 255, 0)) for image in partial_thumbs: if image: content = BytesIO(image) try: img = Image.open(content) img.verify() # verify that it is, in fact an image img = Image.open(BytesIO(image)) # "re-open" the file (required after running verify method) merged_partial_thumbs.paste(img, mask=img.convert('RGBA')) except UnidentifiedImageError as e: logger.error(f"Thumbnail generation. Error occurred while fetching dataset image: {image}") logger.exception(e) # --- fetch background image --- try: BackgroundGenerator = import_string(settings.THUMBNAIL_BACKGROUND["class"]) background = BackgroundGenerator(width, height).fetch(bbox, background_zoom) if bbox else None except Exception as e: logger.error(f"Thumbnail generation. Error occurred while fetching background image: {e}") logger.exception(e) background = None # --- overlay image with background --- thumbnail = Image.new("RGB", (width, height), (250, 250, 250)) if background is not None: thumbnail.paste(background, (0, 0)) thumbnail.paste(merged_partial_thumbs, (0, 0), merged_partial_thumbs) # convert image to the format required by save_thumbnail with BytesIO() as output: thumbnail.save(output, format="PNG") content = output.getvalue() # save thumbnail instance.save_thumbnail(default_thumbnail_name, image=content) return instance.thumbnail_url