def get_plant_properties(plant_id: int, taxon_id: int = None, db: Session = Depends(get_db)): """reads a plant's property values from db; plus it's taxon's property values""" load_properties = LoadProperties() categories = load_properties.get_properties_for_plant(plant_id, db) categories_taxon = load_properties.get_properties_for_taxon( taxon_id, db) if taxon_id else [] make_list_items_json_serializable(categories) make_list_items_json_serializable(categories_taxon) results = { 'propertyCollections': { "categories": categories }, 'plant_id': plant_id, 'propertyCollectionsTaxon': { "categories": categories_taxon }, 'taxon_id': taxon_id, 'action': 'Get', 'resource': 'PropertyTaxaResource', 'message': get_message( f"Receiving properties for plant {plant_id} from database.") } return results
async def get_plants(db: Session = Depends(get_db)): """read (almost unfiltered) plants information from db""" # select plants from database # filter out hidden ("deleted" in frontend but actually only flagged hidden) plants query = db.query(Plant) if config.filter_hidden: # sqlite does not like "is None" and pylint doesn't like "== None" query = query.filter((Plant.hide.is_(False)) | (Plant.hide.is_(None))) if DEMO_MODE_RESTRICT_TO_N_PLANTS: query = query.limit(DEMO_MODE_RESTRICT_TO_N_PLANTS) plants_obj = query.all() plants_list = [p.as_dict() for p in plants_obj] make_list_items_json_serializable(plants_list) results = { 'action': 'Get plants', 'resource': 'PlantResource', 'message': get_message(f"Loaded {len(plants_list)} plants from database."), 'PlantsCollection': plants_list } return results
def delete_plant(request: Request, data: PPlantsDeleteRequest, db: Session = Depends(get_db)): """tag deleted plant as 'hide' in database""" args = data record_update: Plant = db.query(Plant).filter_by( plant_name=args.plant).first() if not record_update: logger.error( f'Plant to be deleted not found in database: {args.plant}.') throw_exception( f'Plant to be deleted not found in database: {args.plant}.', request=request) record_update.hide = True db.commit() logger.info(message := f'Deleted plant {args.plant}') results = { 'action': 'Deleted plant', 'resource': 'PlantResource', 'message': get_message(message, description=f'Plant name: {args.plant}\nHide: True') } return results
async def upload_images(request: Request, db: Session = Depends(get_db)): """upload new image(s) todo: switch key in supplied plants list to id""" # the ui5 uploader control does somehow not work with the expected form/multipart format expected # via fastapi argument files = List[UploadFile] = File(...) # therefore, we directly go on the starlette request object form = await request.form() additional_data = json.loads(form.get('files-data')) # noinspection PyTypeChecker files: List[UploadFile] = form.getlist('files[]') # validate arguments manually as pydantic doesn't trigger here try: PImageUploadedMetadata(**additional_data) except ValidationError as err: throw_exception(str(err), request=request) # todo: get rid of that key/text/keyword dict plants = tuple( { 'key': p, 'text': p } for p in additional_data['plants']) # if 'plants' in additional_data else [] keywords = tuple({ 'keyword': k, 'text': k } for k in additional_data['keywords']) # if 'keywords' in additional_data else [] # remove duplicates (filename already exists in file system) duplicate_filenames = remove_files_already_existing(files, RESIZE_SUFFIX) photos: List[Photo] = await _save_image_files(files=files, request=request, plants=plants, keywords=keywords) photo_files_ext = _get_pimages_from_photos(photos, db=db) msg = get_message(f'Saved {len(files)} images.' + (' Duplicates found.' if duplicate_filenames else ''), message_type=MessageType.WARNING if duplicate_filenames else MessageType.INFORMATION, description=f'Saved: {[p.filename for p in files]}.' f'\nSkipped Duplicates: {duplicate_filenames}.') logger.info(msg['message']) results = { 'action': 'Uploaded', 'resource': 'ImageResource', 'message': msg, 'images': photo_files_ext } return results
async def modify_plant_properties(data: PPropertiesModifiedPlant, db: Session = Depends(get_db)): """save plant properties""" SaveProperties().save_properties(data.modifiedPropertiesPlants, db=db) results = { 'action': 'Update', 'resource': 'PropertyResource', 'message': get_message(f'Updated properties in database.') } return results
async def refresh_photo_directory(): """recreates the photo directory, i.e. re-reads directory, creates missing thumbnails etc.""" with lock_photo_directory: get_photo_directory().refresh_directory() logger.info(message := f'Refreshed photo directory') results = { 'action': 'Function refresh Photo Directory', 'resource': 'RefreshPhotoDirectoryResource', 'message': get_message(message) } return results
async def get_selection_data(db: Session = Depends(get_db)): """build & return taxon tree for advanced filtering""" taxon_tree = build_taxon_tree(db) make_list_items_json_serializable(taxon_tree) results = { 'action': 'Get taxon tree', 'resource': 'SelectionResource', 'message': get_message(f"Loaded selection data."), 'Selection': { 'TaxonTree': taxon_tree } } return results
async def modify_taxon_properties(data: PPropertiesModifiedTaxon, db: Session = Depends(get_db)): """taxon properties; note: there's no get method for taxon properties; they are read with the plant's properties save taxon properties""" SavePropertiesTaxa().save_properties( properties_modified=data.modifiedPropertiesTaxa, db=db) results = { 'action': 'Update', 'resource': 'PropertyTaxaResource', 'message': get_message(f'Updated properties for taxa in database.') } return results
async def delete_image(request: Request, photo: PImageDelete): """move the file that should be deleted to another folder (not actually deleted, currently)""" old_path = photo.path_full_local if not os.path.isfile(old_path): logger.error( err_msg := f"File selected to be deleted not found: {old_path}") throw_exception(err_msg, request=request) filename = os.path.basename(old_path) new_path = os.path.join(PATH_DELETED_PHOTOS, filename) try: os.replace( src=old_path, dst=new_path) # silently overwrites if privileges are sufficient except OSError as e: logger.error(err_msg := f'OSError when moving file {old_path} to {new_path}', exc_info=e) throw_exception(err_msg, description=f'Filename: {os.path.basename(old_path)}', request=request) logger.info(f'Moved file {old_path} to {new_path}') # remove from PhotoDirectory cache with lock_photo_directory: photo_directory = get_photo_directory(instantiate=False) if photo_directory: photo_obj = photo_directory.get_photo(photo.path_full_local) photo_directory.remove_image_from_directory(photo_obj) results = { 'action': 'Deleted', 'resource': 'ImageResource', 'message': get_message(f'Successfully deleted image', description=f'Filename: {os.path.basename(old_path)}'), 'photo': photo } # send the photo back to frontend; it will be removed from json model there return results
def rename_plant(request: Request, data: PPlantsRenameRequest, db: Session = Depends(get_db)): """we use the put method to rename a plant""" args = data plant_obj = db.query(Plant).filter( Plant.plant_name == args.OldPlantName).first() if not plant_obj: throw_exception(f"Can't find plant {args.OldPlantName}", request=request) if db.query(Plant).filter(Plant.plant_name == args.NewPlantName).first(): throw_exception(f"Plant already exists: {args.NewPlantName}", request=request) # rename plant name plant_obj.plant_name = args.NewPlantName plant_obj.last_update = datetime.datetime.now() # most difficult task: exif tags use plant name not id; we need to change each plant name occurence # in images' exif tags count_modified_images = rename_plant_in_image_files( args.OldPlantName, args.NewPlantName) # only after image modifications have gone well, we can commit changes to database db.commit() create_history_entry(description=f"Renamed to {args.NewPlantName}", db=db, plant_id=plant_obj.id, plant_name=args.OldPlantName, commit=False) logger.info(f'Modified {count_modified_images} images.') results = { 'action': 'Renamed plant', 'resource': 'PlantResource', 'message': get_message(f'Renamed {args.OldPlantName} to {args.NewPlantName}', description=f'Modified {count_modified_images} images.') } return results
async def get_property_names(db: Session = Depends(get_db)): category_obj = db.query(PropertyCategory).all() categories = {} for cat in category_obj: categories[cat.category_name] = [{ 'property_name': p.property_name, 'property_name_id': p.id, 'countPlants': len(p.property_values) } for p in cat.property_names] results = { 'action': 'Get', 'resource': 'PropertyNameResource', 'propertiesAvailablePerCategory': categories, 'message': get_message(f"Receiving Property Names from database.") } return results
async def upload_images_plant(plant_id: int, request: Request, db: Session = Depends(get_db)): """ upload images and directly assign them to supplied plant; no keywords included # the ui5 uploader control does somehow not work with the expected form/multipart format expected # via fastapi argument files = List[UploadFile] = File(...) # therefore, we directly go on the starlette request object """ form = await request.form() # noinspection PyTypeChecker files: List[UploadFile] = form.getlist('files[]') # remove duplicates (filename already exists in file system) duplicate_filenames = remove_files_already_existing(files, RESIZE_SUFFIX) plant_name = Plant.get_plant_name_by_plant_id(plant_id, db, raise_exception=True) photos: List[Photo] = await _save_image_files(files=files, request=request, plants=({ 'key': plant_name, 'text': plant_name }, )) photo_files_ext = _get_pimages_from_photos(photos, db=db) msg = get_message(f'Saved {len(files)} images.' + (' Duplicates found.' if duplicate_filenames else ''), message_type=MessageType.WARNING if duplicate_filenames else MessageType.INFORMATION, description=f'Saved: {[p.filename for p in files]}.' f'\nSkipped Duplicates: {duplicate_filenames}.') logger.info(msg['message']) results = { 'action': 'Uploaded', 'resource': 'ImageResource', 'message': msg, 'images': photo_files_ext } return results
def modify_plants(data: PPlantsUpdateRequest, db: Session = Depends(get_db)): """update existing or create new plants""" plants_modified = data.PlantsCollection # update plants plants_saved = update_plants_from_list_of_dicts(plants_modified, db) # serialize updated/created plants to refresh data in frontend plants_list = [p.as_dict() for p in plants_saved] make_list_items_json_serializable(plants_list) logger.info(message := f"Saved updates for {len(plants_modified)} plants.") results = { 'action': 'Saved Plants', 'resource': 'PlantResource', 'message': get_message(message), 'plants': plants_list } # return the updated/created plants return results
async def get_events(plant_id: int, db: Session = Depends(get_db)): """returns events from event database table imports: plant_id exports: see PResultsEventResource """ results = [] # might be a newly created plant with no existing events, yet event_objs = Event.get_events_by_plant_id(plant_id, db) for event_obj in event_objs: results.append(event_obj.as_dict()) logger.info( m := f'Receiving {len(results)} events for {Plant.get_plant_name_by_plant_id(plant_id, db)}.' ) results = { 'events': results, 'message': get_message(m, message_type=MessageType.DEBUG) } return results
async def get_images(untagged: bool = False, db: Session = Depends(get_db)): """ get image information for all plants from images and their exif tags including plants and keywords optionally, request only images that have no plants tagged, yet """ # instantiate photo directory if required, get photos in external format from files exif data # with lock_photo_directory: # photo_files_all = get_photo_directory().get_photo_files_ext() with lock_photo_directory: if untagged: photo_files_all = get_photo_directory().get_photo_files_untagged() else: photo_files_all = get_photo_directory().get_photo_files() photo_files_ext = _get_pimages_from_photos(photo_files_all, db=db) # filter out images whose only plants are configured to be inactive inactive_plants = set( p.plant_name for p in db.query(Plant.plant_name).filter_by(hide=True)) photo_files_ext = [ f for f in photo_files_ext if len(f.plants) != 1 or f.plants[0].key not in inactive_plants ] logger.debug( f'Filter out {len(photo_files_all) - len(photo_files_ext)} images due to Hide flag of the only tagged ' f'plant.') # make serializable # make_list_items_json_serializable(photo_files_ext) logger.info(f'Returned {len(photo_files_ext)} images.') results = { 'ImagesCollection': photo_files_ext, 'message': get_message('Loaded images from backend.', description=f'Count: {len(photo_files_ext)}') } return results
def get_proposals(request: Request, entity_id: ProposalEntity, db: Session = Depends(get_db)): """returns proposals for selection tables""" results = {} if entity_id == ProposalEntity.SOIL: results = {'SoilsCollection': [], 'ComponentsCollection': []} # soil mixes soils = db.query(Soil).all() for soil in soils: soil_dict = soil.as_dict() soil_dict['components'] = [{ 'component_name': c.soil_component.component_name, 'portion': c.portion } for c in soil.soil_to_component_associations] results['SoilsCollection'].append(soil_dict) # soil components for new mixes components = db.query(SoilComponent).all() results['ComponentsCollection'] = [{ 'component_name': c.component_name } for c in components] elif entity_id == ProposalEntity.NURSERY: # get distinct nurseries/sources, sorted by last update nurseries_tuples = db.query(Plant.nursery_source) \ .order_by(Plant.last_update.desc()) \ .distinct(Plant.nursery_source)\ .filter(Plant.nursery_source.isnot(None)).all() if not nurseries_tuples: results = {'NurseriesSourcesCollection': []} else: results = { 'NurseriesSourcesCollection': [{ 'name': n[0] } for n in nurseries_tuples] } elif entity_id == ProposalEntity.KEYWORD: # return collection of all distinct keywords used in images keywords_set = get_distinct_keywords_from_image_files() keywords_collection = [{ 'keyword': keyword } for keyword in keywords_set] results = {'KeywordsCollection': keywords_collection} elif entity_id == ProposalEntity.TRAIT_CATEGORY: # trait categories trait_categories = [] t: Trait for t in TRAIT_CATEGORIES: # note: trait categories from config file are created in orm_tables.py if not existing upon start trait_category_obj = TraitCategory.get_cat_by_name( t, db, raise_exception=True) trait_categories.append(trait_category_obj.as_dict()) results = {'TraitCategoriesCollection': trait_categories} # traits traits_query: List[Trait] = db.query(Trait).filter( Trait.trait_category.has( TraitCategory.category_name.in_(TRAIT_CATEGORIES))) traits = [] for t in traits_query: t_dict = t.as_dict() t_dict['trait_category_id'] = t.trait_category_id t_dict['trait_category'] = t.trait_category.category_name traits.append(t_dict) results['TraitsCollection'] = traits else: throw_exception(f'Proposal entity {entity_id} not expected.', request=request) results.update({ 'action': 'Get', 'resource': 'ProposalResource', 'message': get_message( f'Receiving proposal values for entity {entity_id} from backend.') }) return results
async def get_taxa(db: Session = Depends(get_db)): """returns taxa from taxon database table""" taxa: List[Taxon] = db.query(Taxon).all() taxon_dict = {} for taxon in taxa: taxon_dict[taxon.id] = taxon.as_dict() if taxon.taxon_to_trait_associations: # build a dict of trait categories categories = {} for link in taxon.taxon_to_trait_associations: if link.trait.trait_category.id not in categories: categories[link.trait.trait_category.id] = { 'id': link.trait.trait_category.id, 'category_name': link.trait.trait_category.category_name, 'sort_flag': link.trait.trait_category.sort_flag, 'traits': [] } categories[link.trait.trait_category.id]['traits'].append({ 'id': link.trait.id, 'trait': link.trait.trait, # 'observed': link.observed, 'status': link.status }) # ui5 frontend requires a list for the json model taxon_dict[taxon.id]['trait_categories'] = list( categories.values()) # images taxon_dict[taxon.id]['images'] = [] if taxon.images: for link_obj in taxon.image_to_taxon_associations: image_obj = link_obj.image path_small = get_thumbnail_relative_path_for_relative_path( image_obj.relative_path, size=config.size_thumbnail_image) taxon_dict[taxon.id]['images'].append({ 'id': image_obj.id, 'path_thumb': path_small, 'path_original': image_obj.relative_path, 'description': link_obj.description }) # distribution codes according to WGSRPD (level 3) taxon_dict[taxon.id]['distribution'] = {'native': [], 'introduced': []} for distribution_obj in taxon.distribution: if distribution_obj.establishment == 'Native': taxon_dict[taxon.id]['distribution']['native'].append( distribution_obj.tdwg_code) elif distribution_obj.establishment == 'Introduced': taxon_dict[taxon.id]['distribution']['introduced'].append( distribution_obj.tdwg_code) # occurence images taxon_dict[taxon.id]['occurrenceImages'] = [ o.as_dict() for o in taxon.occurence_images ] logger.info(message := f'Received {len(taxon_dict)} taxa from database.') results = { 'action': 'Get taxa', 'resource': 'TaxonResource', 'message': get_message(message), 'TaxaDict': taxon_dict } # snake_case is converted to camelCase and date is converted to isoformat # results = PResultsGetTaxa(**results).dict(by_alias=True) return results
async def update_taxa(request: Request, modified_taxa: PModifiedTaxa, db: Session = Depends(get_db)): """two things can be changed in the taxon model, and these are modified in extensions here: - modified custom fields - traits""" modified_taxa = modified_taxa.ModifiedTaxaCollection for taxon_modified in modified_taxa: taxon: Taxon = db.query(Taxon).filter( Taxon.id == taxon_modified.id).first() if not taxon: logger.error(f'Taxon not found: {taxon.name}. Saving canceled.') throw_exception(f'Taxon not found: {taxon.name}. Saving canceled.', request=request) taxon.custom_notes = taxon_modified.custom_notes update_traits(taxon, taxon_modified.trait_categories, db) # changes to images attached to the taxon # deleted images path_originals_saved = [image.path_original for image in taxon_modified.images] if \ taxon_modified.images else [] for image_obj in taxon.images: if image_obj.relative_path not in path_originals_saved: # don't delete image object, but only the association (image might be assigned to other events) db.delete([ link for link in taxon.image_to_taxon_associations if link.image.relative_path == image_obj.relative_path ][0]) # newly assigned images if taxon_modified.images: for image in taxon_modified.images: image_obj = db.query(Image).filter( Image.relative_path == image.path_original).first() # not assigned to any event, yet if not image_obj: image_obj = Image(relative_path=image.path_original) db.add(image_obj) db.flush() # required to obtain id # update link table including the image description current_taxon_to_image_link = [ t for t in taxon.image_to_taxon_associations if t.image == image_obj ] # insert link if not current_taxon_to_image_link: link = ImageToTaxonAssociation( image_id=image_obj.id, taxon_id=taxon.id, description=image.description) db.add(link) logger.info( f'Image {image_obj.relative_path} assigned to taxon {taxon.name}' ) # update description elif current_taxon_to_image_link[ 0].description != image.description: current_taxon_to_image_link[ 0].description = image.description logger.info( f'Update description of link between image {image_obj.relative_path} and taxon' f' {taxon.name}') db.commit() results = { 'action': 'Save taxa', 'resource': 'TaxonResource', 'message': get_message(f'Updated {len(modified_taxa)} taxa in database.') } logger.info(f'Updated {len(modified_taxa)} taxa in database.') return results
logger.info( f'Updating changed image in PhotoDirectory Cache: {photo.path_full_local}' ) photo.tag_keywords = [k.keyword for k in image_ext.keywords] photo.tag_authors_plants = [p.key for p in image_ext.plants] photo.tag_description = image_ext.description photo.write_exif_tags() results = { 'action': 'Saved', 'resource': 'ImageResource', 'message': get_message( f"Saved updates for {len(modified_ext.ImagesCollection)} images.") } return results @router.post("/images/", response_model=PResultsImagesUploaded) async def upload_images(request: Request, db: Session = Depends(get_db)): """upload new image(s) todo: switch key in supplied plants list to id""" # the ui5 uploader control does somehow not work with the expected form/multipart format expected # via fastapi argument files = List[UploadFile] = File(...) # therefore, we directly go on the starlette request object form = await request.form() additional_data = json.loads(form.get('files-data')) # noinspection PyTypeChecker
if event.images: for image in event.images: image_obj = db.query(Image).filter( Image.relative_path == image.path_original).first() # not assigned to any event, yet if not image_obj: image_obj = Image(relative_path=image.path_original) new_list.append(image_obj) # not assigned to that specific event, yet if image_obj not in event_obj.images: event_obj.images.append(image_obj) if new_list: db.add_all(new_list) db.commit() logger.info(' Saving Events: ' + (description := ', '.join( [f'{key}: {counts[key]}' for key in counts.keys()]))) results = { 'action': 'Saved events', 'resource': 'EventResource', 'message': get_message(f'Updated events in database.', description=description) } return results