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
def get_plant_by_plant_id(plant_id: int, db: Session, raise_exception: bool = False) -> Plant: plant = db.query(Plant).filter(Plant.id == plant_id).first() if not plant and raise_exception: throw_exception(f'Plant ID not found in database: {plant_id}') return plant
def get_event_by_event_id(event_id: int, db: Session, raise_exception: bool = False) -> Event: event = db.query(Event).filter(Event.id == event_id).first() if not event and raise_exception: throw_exception(f'Event not found in db: {event_id}') return event
def get_events_by_plant_id(plant_id: int, db: Session, raise_exception: bool = False) -> List[Event]: events = db.query(Event).filter(Event.plant_id == plant_id).all() if not events and raise_exception: throw_exception(f'No events in db for plant: {plant_id}') return events
def get_taxon_by_taxon_id(taxon_id: int, db: Session, raise_exception: bool = False) -> Taxon: taxon = db.query(Taxon).filter(Taxon.id == taxon_id).first() if not taxon and raise_exception: throw_exception(f'Taxon not found in database: {taxon_id}') return taxon
def get_plant_by_plant_name(plant_name: str, db: Session, raise_exception: bool = False) -> Plant: plant = db.query(Plant).filter(Plant.plant_name == plant_name).first() if not plant and raise_exception: throw_exception(f'Plant not found in database: {plant_name}') return plant
def get_plant_name_by_plant_id(plant_id: int, db: Session, raise_exception: bool = False) -> str: plant_name = db.query( Plant.plant_name).filter(Plant.id == plant_id).scalar() if not plant_name and raise_exception: throw_exception(f'Plant not found in database: {plant_id}') return plant_name
def get_plant_id_by_plant_name(plant_name: str, db: Session, raise_exception: bool = False) -> int: plant_id = db.query( Plant.id).filter(Plant.plant_name == plant_name).scalar() if not plant_id and raise_exception: throw_exception(f'Plant ID not found in database: {plant_name}') return plant_id
def get_cat_by_id(category_id: int, db: Session, raise_exception: bool = False) -> PropertyCategory: cat = db.query(PropertyCategory).filter( PropertyCategory.id == category_id).first() if not cat and raise_exception: throw_exception( f'Property Category not found in database: {category_id}') return cat
def get_cat_by_name(category_name: str, db: Session, raise_exception: bool = False) -> TraitCategory: cat = db.query(TraitCategory).filter( TraitCategory.category_name == category_name).first() if not cat and raise_exception: throw_exception( f'Trait Category not found in database: {category_name}') return cat
def get_by_id(property_value_id: int, db: Session, raise_exception: bool = False) -> PropertyValue: property_obj = db.query(PropertyValue).filter( PropertyValue.id == property_value_id).first() if not property_obj and raise_exception: throw_exception( f'No property values found for Property value ID: {property_value_id}' ) return property_obj
def get_by_plant_id(plant_id: int, db: Session, raise_exception: bool = False) -> List[PropertyValue]: property_obj = db.query(PropertyValue).filter( PropertyValue.plant_id == int(plant_id), PropertyValue.taxon_id.is_(None)).all() if not property_obj and raise_exception: throw_exception( f'No property values found for Plant ID: {plant_id}') return property_obj
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 _save_image_files( files: List[UploadFile], request: Request, plants: Tuple = (), keywords: Tuple = (), ) -> List[Photo]: """save the files supplied as starlette uploadfiles on os; assign plants and keywords""" photos = [] for photo_upload in files: # save to file system path = os.path.join(PATH_ORIGINAL_PHOTOS_UPLOADED, photo_upload.filename) logger.info(f'Saving {path}.') async with aiofiles.open(path, 'wb') as out_file: content = await photo_upload.read() # async read await out_file.write(content) # async write # photo_upload.save(path) # we can't use object first and then save as this alters file object # resize file by lowering resolution if required if not config.resizing_size: pass elif not resizing_required(path, config.resizing_size): logger.info(f'No resizing required.') else: logger.info(f'Saving and resizing {path}.') resize_image(path=path, save_to_path=with_suffix(path, RESIZE_SUFFIX), size=config.resizing_size, quality=config.quality) path = with_suffix(path, RESIZE_SUFFIX) # add to photo directory (cache) and add keywords and plant tags # (all the same for each uploaded photo) photo = Photo(path_full_local=path, filename=os.path.basename(path)) photo.tag_authors_plants = [p['key'] for p in plants] photo.tag_keywords = [k['keyword'] for k in keywords] with lock_photo_directory: if p := get_photo_directory(instantiate=False): if p in p.photos: throw_exception( f"Already found in PhotoDirectory cache: {photo.path_full_local}", request=request) p.photos.append(photo) # generate thumbnail image for frontend display and update file's exif tags photo.generate_thumbnails() photo.write_exif_tags() photos.append(photo)
async def get_plant(request: Request, plant_id: int, db: Session = Depends(get_db)): """ read plant information from db currently unused """ plant_obj = db.query(Plant).filter(Plant.id == plant_id).first() if not plant_obj: throw_exception(f'Plant not found: {plant_id}.', request=request) plant = plant_obj.as_dict() make_dict_values_json_serializable(plant) return plant
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 update_images(request: Request, modified_ext: PImageUpdated): """modify existing image's exif tags""" logger.info( f"Saving updates for {len(modified_ext.ImagesCollection)} images.") with lock_photo_directory: directory = get_photo_directory() for image_ext in modified_ext.ImagesCollection: if not (photo := directory.get_photo(image_ext.path_full_local)): throw_exception( f"Can't find image file: {image_ext.path_full_local}", request=request) 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()
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 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
def update_traits(taxon: Taxon, trait_categories: List[PTraitCategoryWithTraits], db: Session): """update a taxon's traits (includes deleting and creating traits)""" # loop at new traits to update attributes and create new ones link: TaxonToTraitAssociation new_trait_obj_list = [] for category_new in trait_categories or []: # might be None # get category object category_obj = db.query(TraitCategory).filter( TraitCategory.id == category_new.id).first() if not category_obj: throw_exception(f'Trait Category {category_new.id} not found.') # loop at category's traits for trait_new in category_new.traits: # check if we have no trait id but a same-named trait if not trait_new.id: trait_obj: Trait = db.query(Trait).filter( Trait.trait == trait_new.trait, Trait.trait_category == category_obj).first() # get trait object by id else: trait_obj: Trait = db.query(Trait).filter( Trait.id == trait_new.id).first() if not trait_obj: throw_exception( f"Can't find trait in db although it has an id: {trait_new.id}" ) # update existing trait's link to taxon (this is where the status attribute lies) if trait_obj: links_existing = [ li for li in trait_obj.taxon_to_trait_associations if li.taxon == taxon ] if links_existing: links_existing[0].status = trait_new.status else: # trait exists, but is not assigned the taxon; create that link link = TaxonToTraitAssociation(taxon=taxon, trait=trait_obj, status=trait_new.status) db.add(link) # taxon.taxon_to_trait_associations.append(trait_obj) # commit in calling method # altogether new trait else: logger.info(f"Creating new trait in db for category " f"{category_obj.category_name}: {trait_new.trait}") trait_obj = Trait(trait=trait_new.trait, trait_category=category_obj) link = TaxonToTraitAssociation(taxon=taxon, trait=trait_obj, status=trait_new.status) db.add_all([trait_obj, link]) # collect traits themselves for identifying deleted links later new_trait_obj_list.append(trait_obj) # remove deleted traits from taxon links for link in taxon.taxon_to_trait_associations: if link.trait not in new_trait_obj_list: logger.info( f"Deleting trait for taxon {taxon.name}: {link.trait.trait}") db.delete(link)
async def modify_events( request: Request, # todo replace dict with ordinary pydantic schema (also on ui side) plants_events_dict: Dict[str, List[PEventNew]] = Body(..., embed=True), db: Session = Depends(get_db)): """save n events for n plants in database (add, modify, delete)""" # frontend submits a dict with events for those plants where at least one event has been changed, added, or # deleted. it does, however, always submit all these plants' events # loop at the plants and their events counts = defaultdict(int) new_list = [] for plant_name, events in plants_events_dict.items(): plant_obj = Plant.get_plant_by_plant_name(plant_name, db, raise_exception=True) logger.info( f'Plant {plant_obj.plant_name} has {len(plant_obj.events)} events in db:' f' {[e.id for e in plant_obj.events]}') # event might have no id in browser but already in backend from earlier save # so try to get eventid from plant name and date (pseudo-key) to avoid events being deleted # note: if we "replace" an event in the browser (i.e. for a specific date, we delete an event and # create a new one, then that event in database will be modified, not deleted and re-created for event in [e for e in events if not e.id]: event_obj_id = db.query(Event.id).filter( Event.plant_id == plant_obj.id, Event.date == event.date).scalar() if event_obj_id is not None: event.id = event_obj_id logger.info( f"Identified event without id from browser as id {event.id}" ) event_ids = [e.id for e in events] logger.info( f'Updating {len(events)} events ({event_ids})for plant {plant_name}' ) # loop at the current plant's database events to find deleted ones event_obj: Optional[Event] = None for event_obj in plant_obj.events: if event_obj.id not in event_ids: logger.info(f'Deleting event {event_obj.id}') for link in event_obj.image_to_event_associations: db.delete(link) db.delete(event_obj) counts['Deleted Events'] += 1 # loop at the current plant's events from frontend to find new events and modify existing ones for event in events: # new event if not event.id: # create event record logger.info('Creating event.') event_obj = Event(date=event.date, event_notes=event.event_notes, plant=plant_obj) db.add(event_obj) counts['Added Events'] += 1 # update existing event else: try: logger.info(f'Getting event {event.id}.') event_obj = Event.get_event_by_event_id(event.id, db) if not event_obj: logger.warning(f'Event not found: {event.id}') continue event_obj.event_notes = event.event_notes event_obj.date = event.date except InvalidRequestError as e: db.rollback() logger.error( 'Serious error occured at event resource (POST). Rollback. See log.', stack_info=True, exc_info=e) throw_exception( 'Serious error occured at event resource (POST). Rollback. See log.', request=request) # segments observation, pot, and soil if event.observation and not event_obj.observation: observation_obj = Observation() db.add(observation_obj) event_obj.observation = observation_obj counts['Added Observations'] += 1 elif not event.observation and event_obj.observation: # 1:1 relationship, so we can delete the observation directly db.delete(event_obj.observation) event_obj.observation = None if event.observation and event_obj.observation: event_obj.observation.diseases = event.observation.diseases event_obj.observation.observation_notes = event.observation.observation_notes # cm to mm event_obj.observation.height = event.observation.height * 10 if event.observation.height else None event_obj.observation.stem_max_diameter = event.observation.stem_max_diameter * 10 if \ event.observation.stem_max_diameter else None if not event.pot: event_obj.pot_event_type = None event_obj.pot = None else: event_obj.pot_event_type = event.pot_event_type # add empty if not existing if not event_obj.pot: pot_obj = Pot() db.add(pot_obj) event_obj.pot = pot_obj counts['Added Pots'] += 1 # pot objects have an id but are not "reused" for other events, so we may change it here event_obj.pot.material = event.pot.material event_obj.pot.shape_side = event.pot.shape_side event_obj.pot.shape_top = event.pot.shape_top event_obj.pot.diameter_width = event.pot.diameter_width * 10 if event.pot.diameter_width else None if not event.soil: event_obj.soil_event_type = None # remove soil from event (event to soil is n:1 so we don't delete the soil object but only the # assignment) if event_obj.soil: event_obj.soil = None else: event_obj.soil_event_type = event.soil_event_type # add soil to event or change it if not event_obj.soil or (event.soil and event.soil.id != event_obj.soil.id): event_obj.soil = get_or_create_soil( event.soil.dict(), counts, db) # changes to images attached to the event # deleted images path_originals_saved = [ image.path_original for image in event.images ] if event.images else [] for image_obj in event_obj.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 event_obj.image_to_event_associations if link.image.relative_path == image_obj.relative_path ][0]) # newly assigned images 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()])))