create exceptions.py and move more constants
This commit is contained in:
		
							parent
							
								
									2b34854910
								
							
						
					
					
						commit
						1ecd1f979e
					
				
					 8 changed files with 343 additions and 300 deletions
				
			
		
							
								
								
									
										21
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,21 @@ | |||
| Etiquette | ||||
| ========= | ||||
| 
 | ||||
| This is the readme file. | ||||
| 
 | ||||
| ### Changelog | ||||
| 
 | ||||
| - **[addition]** A new feature was added. | ||||
| - **[bugfix]** Incorrect behavior was fixed. | ||||
| - **[change]** An existing feature was slightly modified or parameters were renamed. | ||||
| - **[cleanup]** Code was improved, comments were added, or other changes with minor impact on the interface. | ||||
| - **[removal]** An old feature was removed. | ||||
| 
 | ||||
|   | ||||
| 
 | ||||
| - 2016 11 28 | ||||
|     - **[addition]** Added `etiquette_upgrader.py`. When an update causes the anatomy of the etiquette database to change, I will increment the `phototagger.DATABASE_VERSION` variable, and add a new function to this script that should automatically make all the necessary changes. Until the database is upgraded, phototagger will not start. Don't forget to make backups just in case. | ||||
| 
 | ||||
| - 2016 11 05 | ||||
|     - **[addition]** Added the ability to download an album as a `.tar` file. No compression is used. I still need to do more experiments to make sure this is working perfectly. | ||||
| 
 | ||||
							
								
								
									
										15
									
								
								constants.py
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								constants.py
									
									
									
									
									
								
							|  | @ -1,5 +1,18 @@ | |||
| import string | ||||
| 
 | ||||
| ALLOWED_ORDERBY_COLUMNS = [ | ||||
|     'extension', | ||||
|     'width', | ||||
|     'height', | ||||
|     'ratio', | ||||
|     'area', | ||||
|     'duration', | ||||
|     'bytes', | ||||
|     'created', | ||||
|     'tagged_at', | ||||
|     'random', | ||||
| ] | ||||
| 
 | ||||
| # Errors and warnings | ||||
| ERROR_DATABASE_OUTOFDATE = 'Database is out-of-date. {current} should be {new}. Please use etiquette_upgrader.py' | ||||
| ERROR_INVALID_ACTION = 'Invalid action' | ||||
|  | @ -21,7 +34,7 @@ VALID_TAG_CHARS = string.ascii_lowercase + string.digits + '_' | |||
| 
 | ||||
| DEFAULT_ID_LENGTH = 12 | ||||
| DEFAULT_DBNAME = 'phototagger.db' | ||||
| DEFAULT_THUMBDIR = '_etiquette\\site_thumbnails' | ||||
| DEFAULT_DATADIR = '.\\_etiquette' | ||||
| DEFAULT_DIGEST_EXCLUDE_FILES = [ | ||||
|     DEFAULT_DBNAME, | ||||
|     'desktop.ini', | ||||
|  |  | |||
							
								
								
									
										35
									
								
								etiquette.py
									
									
									
									
									
								
							
							
						
						
									
										35
									
								
								etiquette.py
									
									
									
									
									
								
							|  | @ -12,20 +12,15 @@ import warnings | |||
| 
 | ||||
| import constants | ||||
| import decorators | ||||
| import exceptions | ||||
| import helpers | ||||
| import jsonify | ||||
| import phototagger | ||||
| 
 | ||||
| try: | ||||
|     sys.path.append('C:\\git\\else\\Bytestring') | ||||
|     sys.path.append('C:\\git\\else\\WebstreamZip') | ||||
|     import bytestring | ||||
|     import webstreamzip | ||||
| except ImportError: | ||||
|     # pip install | ||||
|     # https://raw.githubusercontent.com/voussoir/else/master/_voussoirkit/voussoirkit.zip | ||||
|     from vousoirkit import bytestring | ||||
|     from vousoirkit import webstreamzip | ||||
| # pip install | ||||
| # https://raw.githubusercontent.com/voussoir/else/master/_voussoirkit/voussoirkit.zip | ||||
| from voussoirkit import bytestring | ||||
| from voussoirkit import webstreamzip | ||||
| 
 | ||||
| site = flask.Flask(__name__) | ||||
| site.config.update( | ||||
|  | @ -61,7 +56,7 @@ def delete_synonym(synonym): | |||
|     synonym = phototagger.normalize_tagname(synonym) | ||||
|     try: | ||||
|         master_tag = P.get_tag(synonym) | ||||
|     except phototagger.NoSuchTag: | ||||
|     except exceptions.NoSuchTag: | ||||
|         flask.abort(404, 'That synonym doesnt exist') | ||||
| 
 | ||||
|     if synonym not in master_tag.synonyms(): | ||||
|  | @ -79,19 +74,19 @@ def make_json_response(j, *args, **kwargs): | |||
| def P_album(albumid): | ||||
|     try: | ||||
|         return P.get_album(albumid) | ||||
|     except phototagger.NoSuchAlbum: | ||||
|     except exceptions.NoSuchAlbum: | ||||
|         flask.abort(404, 'That album doesnt exist') | ||||
| 
 | ||||
| def P_photo(photoid): | ||||
|     try: | ||||
|         return P.get_photo(photoid) | ||||
|     except phototagger.NoSuchPhoto: | ||||
|     except exceptions.NoSuchPhoto: | ||||
|         flask.abort(404, 'That photo doesnt exist') | ||||
| 
 | ||||
| def P_tag(tagname): | ||||
|     try: | ||||
|         return P.get_tag(tagname) | ||||
|     except phototagger.NoSuchTag as e: | ||||
|     except exceptions.NoSuchTag as e: | ||||
|         flask.abort(404, 'That tag doesnt exist: %s' % e) | ||||
| 
 | ||||
| def send_file(filepath): | ||||
|  | @ -465,7 +460,7 @@ def get_static(filename): | |||
| def get_tags_core(specific_tag=None): | ||||
|     try: | ||||
|         tags = P.export_tags(phototagger.tag_export_easybake, specific_tag=specific_tag) | ||||
|     except phototagger.NoSuchTag: | ||||
|     except exceptions.NoSuchTag: | ||||
|         flask.abort(404, 'That tag doesnt exist') | ||||
|     tags = tags.split('\n') | ||||
|     tags = [t for t in tags if t != ''] | ||||
|  | @ -516,7 +511,7 @@ def post_edit_album(albumid): | |||
|         tag = request.form[action].strip() | ||||
|         try: | ||||
|             tag = P_tag(tag) | ||||
|         except phototagger.NoSuchTag: | ||||
|         except exceptions.NoSuchTag: | ||||
|             response = {'error': 'That tag doesnt exist', 'tagname': tag} | ||||
|             return make_json_response(response, status=404) | ||||
|         recursive = request.form.get('recursive', False) | ||||
|  | @ -552,7 +547,7 @@ def post_edit_photo(photoid): | |||
| 
 | ||||
|     try: | ||||
|         tag = P.get_tag(tag) | ||||
|     except phototagger.NoSuchTag: | ||||
|     except exceptions.NoSuchTag: | ||||
|         response = {'error': 'That tag doesnt exist', 'tagname': tag} | ||||
|         return make_json_response(response, status=404) | ||||
| 
 | ||||
|  | @ -595,11 +590,11 @@ def post_edit_tags(): | |||
|         status = 400 | ||||
|         try: | ||||
|             response = method(tag) | ||||
|         except phototagger.TagTooShort: | ||||
|         except exceptions.TagTooShort: | ||||
|             response = {'error': constants.ERROR_TAG_TOO_SHORT, 'tagname': tag} | ||||
|         except phototagger.CantSynonymSelf: | ||||
|         except exceptions.CantSynonymSelf: | ||||
|             response = {'error': constants.ERROR_SYNONYM_ITSELF, 'tagname': tag} | ||||
|         except phototagger.NoSuchTag as e: | ||||
|         except exceptions.NoSuchTag as e: | ||||
|             response = {'error': constants.ERROR_NO_SUCH_TAG, 'tagname': tag} | ||||
|         except ValueError as e: | ||||
|             response = {'error': e.args[0], 'tagname': tag} | ||||
|  |  | |||
|  | @ -25,5 +25,5 @@ else: | |||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| print('Starting server') | ||||
| print('Starting server on port %d' % port) | ||||
| http.serve_forever() | ||||
							
								
								
									
										46
									
								
								exceptions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								exceptions.py
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,46 @@ | |||
| class CantSynonymSelf(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class NoSuchAlbum(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class NoSuchGroup(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class NoSuchPhoto(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class NoSuchSynonym(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class NoSuchTag(Exception): | ||||
|     pass | ||||
| 
 | ||||
| 
 | ||||
| class PhotoExists(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class TagExists(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class GroupExists(Exception): | ||||
|     pass | ||||
| 
 | ||||
| 
 | ||||
| class TagTooLong(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class TagTooShort(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class NotExclusive(Exception): | ||||
|     ''' | ||||
|     For when two or more mutually exclusive actions have been requested. | ||||
|     ''' | ||||
|     pass | ||||
| 
 | ||||
| class OutOfOrder(Exception): | ||||
|     ''' | ||||
|     For when a requested range (a, b) has b > a | ||||
|     ''' | ||||
|     pass | ||||
							
								
								
									
										151
									
								
								helpers.py
									
									
									
									
									
								
							
							
						
						
									
										151
									
								
								helpers.py
									
									
									
									
									
								
							|  | @ -1,6 +1,10 @@ | |||
| import math | ||||
| import mimetypes | ||||
| import os | ||||
| 
 | ||||
| import exceptions | ||||
| import constants | ||||
| import warnings | ||||
| 
 | ||||
| def chunk_sequence(sequence, chunk_length, allow_incomplete=True): | ||||
|     ''' | ||||
|  | @ -67,6 +71,42 @@ def fit_into_bounds(image_width, image_height, frame_width, frame_height): | |||
| 
 | ||||
|     return (new_width, new_height) | ||||
| 
 | ||||
| def get_mimetype(filepath): | ||||
|     extension = os.path.splitext(filepath)[1].replace('.', '') | ||||
|     if extension in constants.ADDITIONAL_MIMETYPES: | ||||
|         return constants.ADDITIONAL_MIMETYPES[extension] | ||||
|     mimetype = mimetypes.guess_type(filepath)[0] | ||||
|     if mimetype is not None: | ||||
|         mimetype = mimetype.split('/')[0] | ||||
|     return mimetype | ||||
| 
 | ||||
| def hyphen_range(s): | ||||
|     ''' | ||||
|     Given a string like '1-3', return ints (1, 3) representing lower | ||||
|     and upper bounds. | ||||
| 
 | ||||
|     Supports bytestring.parsebytes and hh:mm:ss format. | ||||
|     ''' | ||||
|     s = s.strip() | ||||
|     s = s.replace(' ', '') | ||||
|     if not s: | ||||
|         return (None, None) | ||||
|     parts = s.split('-') | ||||
|     parts = [part.strip() or None for part in parts] | ||||
|     if len(parts) == 1: | ||||
|         low = parts[0] | ||||
|         high = None | ||||
|     elif len(parts) == 2: | ||||
|         (low, high) = parts | ||||
|     else: | ||||
|         raise ValueError('Too many hyphens') | ||||
| 
 | ||||
|     low = _unitconvert(low) | ||||
|     high = _unitconvert(high) | ||||
|     if low is not None and high is not None and low > high: | ||||
|         raise exceptions.OutOfOrder(s, low, high) | ||||
|     return low, high | ||||
| 
 | ||||
| def hms_to_seconds(hms): | ||||
|     ''' | ||||
|     Convert hh:mm:ss string to an integer seconds. | ||||
|  | @ -133,3 +173,114 @@ def truthystring(s): | |||
|     if s in {'null', 'none'}: | ||||
|         return None | ||||
|     return False | ||||
| 
 | ||||
| #=============================================================================== | ||||
| 
 | ||||
| def _minmax(key, value, minimums, maximums): | ||||
|     ''' | ||||
|     When searching, this function dissects a hyphenated range string | ||||
|     and inserts the correct k:v pair into both minimums and maximums. | ||||
|     ('area', '100-200', {}, {}) --> {'area': 100}, {'area': 200} (MODIFIED IN PLACE) | ||||
|     ''' | ||||
|     if value is None: | ||||
|         return | ||||
|     if isinstance(value, (int, float)): | ||||
|         minimums[key] = value | ||||
|         return | ||||
|     try: | ||||
|         (low, high) = hyphen_range(value) | ||||
|     except ValueError: | ||||
|         warnings.warn(constants.WARNING_MINMAX_INVALID.format(field=key, value=value)) | ||||
|         return | ||||
|     except exceptions.OutOfOrder as e: | ||||
|         warnings.warn(constants.WARNING_MINMAX_OOO.format(field=key, min=e.args[1], max=e.args[2])) | ||||
|         return | ||||
|     if low is not None: | ||||
|         minimums[key] = low | ||||
|     if high is not None: | ||||
|         maximums[key] = high | ||||
| 
 | ||||
| def _normalize_extensions(extensions): | ||||
|     ''' | ||||
|     When searching, this function normalizes the list of inputted extensions. | ||||
|     ''' | ||||
|     if isinstance(extensions, str): | ||||
|         extensions = extensions.split() | ||||
|     if extensions is None: | ||||
|         return set() | ||||
|     extensions = [e.lower().strip('.').strip() for e in extensions] | ||||
|     extensions = set(e for e in extensions if e) | ||||
|     return extensions | ||||
| 
 | ||||
| def _orderby(orderby): | ||||
|     ''' | ||||
|     When searching, this function ensures that the user has entered a valid orderby | ||||
|     query, and normalizes the query text. | ||||
| 
 | ||||
|     'random asc' --> ('random', 'asc') | ||||
|     'area' --> ('area', 'desc') | ||||
|     ''' | ||||
|     orderby = orderby.lower().strip() | ||||
|     if orderby == '': | ||||
|         return None | ||||
| 
 | ||||
|     orderby = orderby.split(' ') | ||||
|     if len(orderby) == 2: | ||||
|         (column, sorter) = orderby | ||||
|     elif len(orderby) == 1: | ||||
|         column = orderby[0] | ||||
|         sorter = 'desc' | ||||
|     else: | ||||
|         return None | ||||
| 
 | ||||
|     #print(column, sorter) | ||||
|     if column not in constants.ALLOWED_ORDERBY_COLUMNS: | ||||
|         warnings.warn(constants.WARNING_ORDERBY_BADCOL.format(column=column)) | ||||
|         return None | ||||
|     if column == 'random': | ||||
|         column = 'RANDOM()' | ||||
| 
 | ||||
|     if sorter not in ['desc', 'asc']: | ||||
|         warnings.warn(constants.WARNING_ORDERBY_BADSORTER.format(column=column, sorter=sorter)) | ||||
|         sorter = 'desc' | ||||
|     return (column, sorter) | ||||
| 
 | ||||
| def _setify_tags(photodb, tags, warn_bad_tags=False): | ||||
|     ''' | ||||
|     When searching, this function converts the list of tag strings that the user | ||||
|     requested into Tag objects. If a tag doesn't exist we'll either raise an exception | ||||
|     or just issue a warning. | ||||
|     ''' | ||||
|     if tags is None: | ||||
|         return set() | ||||
| 
 | ||||
|     tagset = set() | ||||
|     for tag in tags: | ||||
|         tag = tag.strip() | ||||
|         if tag == '': | ||||
|             continue | ||||
|         try: | ||||
|             tag = photodb.get_tag(tag) | ||||
|             tagset.add(tag) | ||||
|         except NoSuchTag: | ||||
|             if warn_bad_tags: | ||||
|                 warnings.warn(constants.WARNING_NO_SUCH_TAG.format(tag=tag)) | ||||
|                 continue | ||||
|             else: | ||||
|                 raise | ||||
| 
 | ||||
|     return tagset | ||||
| 
 | ||||
| def _unitconvert(value): | ||||
|     ''' | ||||
|     When parsing hyphenated ranges, this function is used to convert | ||||
|     strings like "1k" to 1024 and "1:00" to 60. | ||||
|     ''' | ||||
|     if value is None: | ||||
|         return None | ||||
|     if ':' in value: | ||||
|         return helpers.hms_to_seconds(value) | ||||
|     elif all(c in '0123456789.' for c in value): | ||||
|         return float(value) | ||||
|     else: | ||||
|         return bytestring.parsebytes(value) | ||||
|  |  | |||
							
								
								
									
										369
									
								
								phototagger.py
									
									
									
									
									
								
							
							
						
						
									
										369
									
								
								phototagger.py
									
									
									
									
									
								
							|  | @ -17,21 +17,14 @@ import warnings | |||
| 
 | ||||
| import constants | ||||
| import decorators | ||||
| import exceptions | ||||
| import helpers | ||||
| 
 | ||||
| try: | ||||
|     sys.path.append('C:\\git\\else\\Bytestring') | ||||
|     sys.path.append('C:\\git\\else\\Pathclass') | ||||
|     sys.path.append('C:\\git\\else\\SpinalTap') | ||||
|     import bytestring | ||||
|     import pathclass | ||||
|     import spinal | ||||
| except ImportError: | ||||
|     # pip install | ||||
|     # https://raw.githubusercontent.com/voussoir/else/master/_voussoirkit/voussoirkit.zip | ||||
|     from voussoirkit import bytestring | ||||
|     from voussoirkit import pathclass | ||||
|     from voussoirkit import spinal | ||||
| # pip install | ||||
| # https://raw.githubusercontent.com/voussoir/else/master/_voussoirkit/voussoirkit.zip | ||||
| from voussoirkit import bytestring | ||||
| from voussoirkit import pathclass | ||||
| from voussoirkit import spinal | ||||
| 
 | ||||
| try: | ||||
|     ffmpeg = converter.Converter( | ||||
|  | @ -185,164 +178,11 @@ CREATE INDEX IF NOT EXISTS index_grouprel_parentid on tag_group_rel(parentid); | |||
| CREATE INDEX IF NOT EXISTS index_grouprel_memberid on tag_group_rel(memberid); | ||||
| '''.format(user_version=DATABASE_VERSION) | ||||
| 
 | ||||
| ALLOWED_ORDERBY_COLUMNS = [ | ||||
|     'extension', | ||||
|     'width', | ||||
|     'height', | ||||
|     'ratio', | ||||
|     'area', | ||||
|     'duration', | ||||
|     'bytes', | ||||
|     'created', | ||||
|     'tagged_at', | ||||
|     'random', | ||||
| ] | ||||
| 
 | ||||
| def _helper_extension(ext): | ||||
|     ''' | ||||
|     When searching, this function normalizes the list of permissible extensions. | ||||
|     ''' | ||||
|     if isinstance(ext, str): | ||||
|         ext = [ext] | ||||
|     if ext is None: | ||||
|         return set() | ||||
|     ext = [e.lower().strip('.') for e in ext] | ||||
|     ext = [e for e in ext if e] | ||||
|     ext = set(ext) | ||||
|     return ext | ||||
| 
 | ||||
| def _helper_filenamefilter(subject, terms): | ||||
|     basename = subject.lower() | ||||
|     return all(term in basename for term in terms) | ||||
| 
 | ||||
| def _helper_minmax(key, value, minimums, maximums): | ||||
|     ''' | ||||
|     When searching, this function dissects a hyphenated range string | ||||
|     and inserts the correct k:v pair into both minimums and maximums. | ||||
|     ''' | ||||
|     if value is None: | ||||
|         return | ||||
|     if isinstance(value, (int, float)): | ||||
|         minimums[key] = value | ||||
|         return | ||||
|     try: | ||||
|         (low, high) = hyphen_range(value) | ||||
|     except ValueError: | ||||
|         warnings.warn(constants.WARNING_MINMAX_INVALID.format(field=key, value=value)) | ||||
|         return | ||||
|     except OutOfOrder as e: | ||||
|         warnings.warn(constants.WARNING_MINMAX_OOO.format(field=key, min=e.args[1], max=e.args[2])) | ||||
|         return | ||||
|     if low is not None: | ||||
|         minimums[key] = low | ||||
|     if high is not None: | ||||
|         maximums[key] = high | ||||
| 
 | ||||
| def _helper_orderby(orderby): | ||||
|     ''' | ||||
|     When searching, this function ensures that the user has entered a valid orderby | ||||
|     query, and normalizes the query text. | ||||
|     ''' | ||||
|     orderby = orderby.lower().strip() | ||||
|     if orderby == '': | ||||
|         return None | ||||
| 
 | ||||
|     orderby = orderby.split(' ') | ||||
|     if len(orderby) == 2: | ||||
|         (column, sorter) = orderby | ||||
|     elif len(orderby) == 1: | ||||
|         column = orderby[0] | ||||
|         sorter = 'desc' | ||||
|     else: | ||||
|         return None | ||||
| 
 | ||||
|     #print(column, sorter) | ||||
|     if column not in ALLOWED_ORDERBY_COLUMNS: | ||||
|         warnings.warn(constants.WARNING_ORDERBY_BADCOL.format(column=column)) | ||||
|         return None | ||||
|     if column == 'random': | ||||
|         column = 'RANDOM()' | ||||
| 
 | ||||
|     if sorter not in ['desc', 'asc']: | ||||
|         warnings.warn(constants.WARNING_ORDERBY_BADSORTER.format(column=column, sorter=sorter)) | ||||
|         sorter = 'desc' | ||||
|     return (column, sorter) | ||||
| 
 | ||||
| def _helper_setify(photodb, l, warn_bad_tags=False): | ||||
|     ''' | ||||
|     When searching, this function converts the list of tag strings that the user | ||||
|     requested into Tag objects. If a tag doesn't exist we'll either raise an exception | ||||
|     or just issue a warning. | ||||
|     ''' | ||||
|     if l is None: | ||||
|         return set() | ||||
| 
 | ||||
|     s = set() | ||||
|     for tag in l: | ||||
|         tag = tag.strip() | ||||
|         if tag == '': | ||||
|             continue | ||||
|         try: | ||||
|             tag = photodb.get_tag(tag) | ||||
|         except NoSuchTag: | ||||
|             if not warn_bad_tags: | ||||
|                 raise | ||||
|             warnings.warn(constants.WARNING_NO_SUCH_TAG.format(tag=tag)) | ||||
|             continue | ||||
|         else: | ||||
|             s.add(tag) | ||||
|     return s | ||||
| 
 | ||||
| def _helper_unitconvert(value): | ||||
|     ''' | ||||
|     When parsing hyphenated ranges, this function is used to convert | ||||
|     strings like "1k" to 1024 and "1:00" to 60. | ||||
|     ''' | ||||
|     if value is None: | ||||
|         return None | ||||
|     if ':' in value: | ||||
|         return helpers.hms_to_seconds(value) | ||||
|     elif all(c in '0123456789.' for c in value): | ||||
|         return float(value) | ||||
|     else: | ||||
|         return bytestring.parsebytes(value) | ||||
| 
 | ||||
| def hyphen_range(s): | ||||
|     ''' | ||||
|     Given a string like '1-3', return ints (1, 3) representing lower | ||||
|     and upper bounds. | ||||
| 
 | ||||
|     Supports bytestring.parsebytes and hh:mm:ss format. | ||||
|     ''' | ||||
|     s = s.strip() | ||||
|     s = s.replace(' ', '') | ||||
|     if not s: | ||||
|         return (None, None) | ||||
|     parts = s.split('-') | ||||
|     parts = [part.strip() or None for part in parts] | ||||
|     if len(parts) == 1: | ||||
|         low = parts[0] | ||||
|         high = None | ||||
|     elif len(parts) == 2: | ||||
|         (low, high) = parts | ||||
|     else: | ||||
|         raise ValueError('Too many hyphens') | ||||
| 
 | ||||
|     low = _helper_unitconvert(low) | ||||
|     high = _helper_unitconvert(high) | ||||
|     if low is not None and high is not None and low > high: | ||||
|         raise OutOfOrder(s, low, high) | ||||
|     return low, high | ||||
| 
 | ||||
| def get_mimetype(filepath): | ||||
|     extension = os.path.splitext(filepath)[1].replace('.', '') | ||||
|     if extension in constants.ADDITIONAL_MIMETYPES: | ||||
|         return constants.ADDITIONAL_MIMETYPES[extension] | ||||
|     mimetype = mimetypes.guess_type(filepath)[0] | ||||
|     if mimetype is not None: | ||||
|         mimetype = mimetype.split('/')[0] | ||||
|     return mimetype | ||||
| 
 | ||||
| def getnow(timestamp=True): | ||||
|     ''' | ||||
|     Return the current UTC timestamp or datetime object. | ||||
|  | @ -376,9 +216,9 @@ def normalize_tagname(tagname): | |||
|     tagname = ''.join(tagname) | ||||
| 
 | ||||
|     if len(tagname) < constants.MIN_TAG_NAME_LENGTH: | ||||
|         raise TagTooShort(tagname) | ||||
|         raise exceptions.TagTooShort(tagname) | ||||
|     if len(tagname) > constants.MAX_TAG_NAME_LENGTH: | ||||
|         raise TagTooLong(tagname) | ||||
|         raise exceptions.TagTooLong(tagname) | ||||
| 
 | ||||
|     return tagname | ||||
| 
 | ||||
|  | @ -437,9 +277,9 @@ def searchfilter_expression(photo_tags, expression, frozen_children, warn_bad_ta | |||
|                 value = any(option in photo_tags for option in frozen_children[token]) | ||||
|             except KeyError: | ||||
|                 if warn_bad_tags: | ||||
|                     warnings.warn(constants.NO_SUCH_TAG.format(tag=token)) | ||||
|                     warnings.warn(constants.WARNING_NO_SUCH_TAG.format(tag=token)) | ||||
|                 else: | ||||
|                     raise NoSuchTag(token) | ||||
|                     raise exceptions.NoSuchTag(token) | ||||
|                 return False | ||||
|             operand_stack.append(value) | ||||
|             if has_operand: | ||||
|  | @ -573,50 +413,6 @@ def tag_export_totally_flat(tags): | |||
|                 result[synonym] = children | ||||
|     return result | ||||
| 
 | ||||
| #################################################################################################### | ||||
| #################################################################################################### | ||||
| 
 | ||||
| class CantSynonymSelf(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class NoSuchAlbum(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class NoSuchGroup(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class NoSuchPhoto(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class NoSuchSynonym(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class NoSuchTag(Exception): | ||||
|     pass | ||||
| 
 | ||||
| 
 | ||||
| class PhotoExists(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class TagExists(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class GroupExists(Exception): | ||||
|     pass | ||||
| 
 | ||||
| 
 | ||||
| class TagTooLong(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class TagTooShort(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class XORException(Exception): | ||||
|     pass | ||||
| 
 | ||||
| class OutOfOrder(Exception): | ||||
|     pass | ||||
| 
 | ||||
| 
 | ||||
| #################################################################################################### | ||||
| #################################################################################################### | ||||
|  | @ -632,15 +428,16 @@ class PDBAlbumMixin: | |||
|         ''' | ||||
|         filepath = os.path.abspath(filepath) | ||||
|         self.cur.execute('SELECT * FROM albums WHERE associated_directory == ?', [filepath]) | ||||
|         f = self.cur.fetchone() | ||||
|         if f is None: | ||||
|             raise NoSuchAlbum(filepath) | ||||
|         return self.get_album(f[SQL_ALBUM['id']]) | ||||
|         fetch = self.cur.fetchone() | ||||
|         if fetch is None: | ||||
|             raise exceptions.NoSuchAlbum(filepath) | ||||
|         return self.get_album(fetch[SQL_ALBUM['id']]) | ||||
| 
 | ||||
|     def get_albums(self): | ||||
|         yield from self.get_things(thing_type='album') | ||||
| 
 | ||||
|     def new_album(self, | ||||
|     def new_album( | ||||
|             self, | ||||
|             associated_directory=None, | ||||
|             commit=True, | ||||
|             description=None, | ||||
|  | @ -691,7 +488,7 @@ class PDBPhotoMixin: | |||
|         self.cur.execute('SELECT * FROM photos WHERE filepath == ?', [filepath]) | ||||
|         fetch = self.cur.fetchone() | ||||
|         if fetch is None: | ||||
|             raise_no_such_thing(NoSuchPhoto, thing_name=filepath) | ||||
|             raise_no_such_thing(exceptions.NoSuchPhoto, thing_name=filepath) | ||||
|         photo = Photo(self, fetch) | ||||
|         return photo | ||||
| 
 | ||||
|  | @ -706,10 +503,10 @@ class PDBPhotoMixin: | |||
|         temp_cur = self.sql.cursor() | ||||
|         temp_cur.execute('SELECT * FROM photos ORDER BY created DESC') | ||||
|         while True: | ||||
|             f = temp_cur.fetchone() | ||||
|             if f is None: | ||||
|             fetch = temp_cur.fetchone() | ||||
|             if fetch is None: | ||||
|                 break | ||||
|             photo = Photo(self, f) | ||||
|             photo = Photo(self, fetch) | ||||
| 
 | ||||
|             yield photo | ||||
| 
 | ||||
|  | @ -750,7 +547,7 @@ class PDBPhotoMixin: | |||
|         database. Tags may be applied now or later. | ||||
| 
 | ||||
|         If `allow_duplicates` is False, we will first check the database for any files | ||||
|         with the same path and raise PhotoExists if found. | ||||
|         with the same path and raise exceptions.PhotoExists if found. | ||||
| 
 | ||||
|         Returns the Photo object. | ||||
|         ''' | ||||
|  | @ -759,10 +556,10 @@ class PDBPhotoMixin: | |||
|         if not allow_duplicates: | ||||
|             try: | ||||
|                 existing = self.get_photo_by_path(filename) | ||||
|             except NoSuchPhoto: | ||||
|             except exceptions.NoSuchPhoto: | ||||
|                 pass | ||||
|             else: | ||||
|                 exc = PhotoExists(filename, existing) | ||||
|                 exc = exceptions.PhotoExists(filename, existing) | ||||
|                 exc.photo = existing | ||||
|                 raise exc | ||||
| 
 | ||||
|  | @ -874,7 +671,7 @@ class PDBPhotoMixin: | |||
|         QUERY OPTIONS | ||||
|         warn_bad_tags: | ||||
|             If a tag is not found, issue a warning but continue the search. | ||||
|             Otherwise, a NoSuchTag exception would be raised. | ||||
|             Otherwise, a exceptions.NoSuchTag exception would be raised. | ||||
| 
 | ||||
|         limit: | ||||
|             The maximum number of *successful* results to yield. | ||||
|  | @ -890,18 +687,18 @@ class PDBPhotoMixin: | |||
|         start_time = time.time() | ||||
|         maximums = {} | ||||
|         minimums = {} | ||||
|         _helper_minmax('area', area, minimums, maximums) | ||||
|         _helper_minmax('created', created, minimums, maximums) | ||||
|         _helper_minmax('width', width, minimums, maximums) | ||||
|         _helper_minmax('height', height, minimums, maximums) | ||||
|         _helper_minmax('ratio', ratio, minimums, maximums) | ||||
|         _helper_minmax('bytes', bytes, minimums, maximums) | ||||
|         _helper_minmax('duration', duration, minimums, maximums) | ||||
|         helpers._minmax('area', area, minimums, maximums) | ||||
|         helpers._minmax('created', created, minimums, maximums) | ||||
|         helpers._minmax('width', width, minimums, maximums) | ||||
|         helpers._minmax('height', height, minimums, maximums) | ||||
|         helpers._minmax('ratio', ratio, minimums, maximums) | ||||
|         helpers._minmax('bytes', bytes, minimums, maximums) | ||||
|         helpers._minmax('duration', duration, minimums, maximums) | ||||
|         orderby = orderby or [] | ||||
| 
 | ||||
|         extension = _helper_extension(extension) | ||||
|         extension_not = _helper_extension(extension_not) | ||||
|         mimetype = _helper_extension(mimetype) | ||||
|         extension = helpers._normalize_extensions(extension) | ||||
|         extension_not = helpers._normalize_extensions(extension_not) | ||||
|         mimetype = helpers._normalize_extensions(mimetype) | ||||
| 
 | ||||
|         if filename is not None: | ||||
|             if not isinstance(filename, str): | ||||
|  | @ -909,14 +706,14 @@ class PDBPhotoMixin: | |||
|             filename = set(term.lower() for term in filename.strip().split(' ')) | ||||
| 
 | ||||
|         if (tag_musts or tag_mays or tag_forbids) and tag_expression: | ||||
|             raise XORException('Expression filter cannot be used with musts, mays, forbids') | ||||
|             raise exceptions.NotExclusive('Expression filter cannot be used with musts, mays, forbids') | ||||
| 
 | ||||
|         tag_musts = _helper_setify(self, tag_musts, warn_bad_tags=warn_bad_tags) | ||||
|         tag_mays = _helper_setify(self, tag_mays, warn_bad_tags=warn_bad_tags) | ||||
|         tag_forbids = _helper_setify(self, tag_forbids, warn_bad_tags=warn_bad_tags) | ||||
|         tag_musts = helpers._setify_tags(photodb=self, tags=tag_musts, warn_bad_tags=warn_bad_tags) | ||||
|         tag_mays = helpers._setify_tags(photodb=self, tags=tag_mays, warn_bad_tags=warn_bad_tags) | ||||
|         tag_forbids = helpers._setify_tags(photodb=self, tags=tag_forbids, warn_bad_tags=warn_bad_tags) | ||||
| 
 | ||||
|         query = 'SELECT * FROM photos' | ||||
|         orderby = [_helper_orderby(o) for o in orderby] | ||||
|         orderby = [helpers._orderby(o) for o in orderby] | ||||
|         orderby = [o for o in orderby if o] | ||||
|         if orderby: | ||||
|             whereable_columns = [o[0] for o in orderby if o[0] != 'RANDOM()'] | ||||
|  | @ -1025,14 +822,14 @@ class PDBTagMixin: | |||
|         Redirect to get_tag_by_id or get_tag_by_name after xor-checking the parameters. | ||||
|         ''' | ||||
|         if not helpers.is_xor(id, name): | ||||
|             raise XORException('One and only one of `id`, `name` can be passed.') | ||||
|             raise exceptions.NotExclusive('One and only one of `id`, `name` can be passed.') | ||||
| 
 | ||||
|         if id is not None: | ||||
|             return self.get_tag_by_id(id) | ||||
|         elif name is not None: | ||||
|             return self.get_tag_by_name(name) | ||||
|         else: | ||||
|             raise_no_such_thing(NoSuchTag, thing_id=id, thing_name=name) | ||||
|             raise_no_such_thing(exceptions.NoSuchTag, thing_id=id, thing_name=name) | ||||
| 
 | ||||
|     def get_tag_by_id(self, id): | ||||
|         return self.get_thing_by_id('tag', thing_id=id) | ||||
|  | @ -1055,7 +852,7 @@ class PDBTagMixin: | |||
|             fetch = self.cur.fetchone() | ||||
|             if fetch is None: | ||||
|                 # was not a top tag or synonym | ||||
|                 raise_no_such_thing(NoSuchTag, thing_name=tagname) | ||||
|                 raise_no_such_thing(exceptions.NoSuchTag, thing_name=tagname) | ||||
|             tagname = fetch[SQL_SYN['master']] | ||||
| 
 | ||||
|     def get_tags(self): | ||||
|  | @ -1068,10 +865,10 @@ class PDBTagMixin: | |||
|         tagname = normalize_tagname(tagname) | ||||
|         try: | ||||
|             self.get_tag_by_name(tagname) | ||||
|         except NoSuchTag: | ||||
|         except exceptions.NoSuchTag: | ||||
|             pass | ||||
|         else: | ||||
|             raise TagExists(tagname) | ||||
|             raise exceptions.TagExists(tagname) | ||||
| 
 | ||||
|         tagid = self.generate_id('tags') | ||||
|         self._cached_frozen_children = None | ||||
|  | @ -1121,10 +918,17 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin): | |||
|     ''' | ||||
|     def __init__( | ||||
|             self, | ||||
|             databasename=constants.DEFAULT_DBNAME, | ||||
|             thumbnail_folder=constants.DEFAULT_THUMBDIR, | ||||
|             id_length=constants.DEFAULT_ID_LENGTH, | ||||
|             databasename=None, | ||||
|             data_directory=None, | ||||
|             id_length=None, | ||||
|         ): | ||||
|         if databasename is None: | ||||
|             databasename = constants.DEFAULT_DBNAME | ||||
|         if data_directory is None: | ||||
|             data_directory = constants.DEFAULT_DATADIR | ||||
|         if id_length is None: | ||||
|             id_length = constants.DEFAULT_ID_LENGTH | ||||
| 
 | ||||
|         self.databasename = databasename | ||||
|         self.database_abspath = os.path.abspath(databasename) | ||||
|         existing_database = os.path.exists(databasename) | ||||
|  | @ -1143,8 +947,11 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin): | |||
|         for statement in statements: | ||||
|             self.cur.execute(statement) | ||||
| 
 | ||||
|         self.thumbnail_folder = os.path.abspath(thumbnail_folder) | ||||
|         os.makedirs(thumbnail_folder, exist_ok=True) | ||||
| 
 | ||||
|         self.data_directory = data_directory | ||||
|         self.thumbnail_folder = os.path.join(data_directory, 'site_thumbnails') | ||||
|         self.thumbnail_folder = os.path.abspath(self.thumbnail_folder) | ||||
|         os.makedirs(self.thumbnail_folder, exist_ok=True) | ||||
| 
 | ||||
|         self.id_length = id_length | ||||
| 
 | ||||
|  | @ -1189,7 +996,7 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin): | |||
|         ) | ||||
|         try: | ||||
|             album = self.get_album_by_path(directory.absolute_path) | ||||
|         except NoSuchAlbum: | ||||
|         except exceptions.NoSuchAlbum: | ||||
|             album = self.new_album( | ||||
|                 associated_directory=directory.absolute_path, | ||||
|                 commit=False, | ||||
|  | @ -1202,7 +1009,7 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin): | |||
|             if current_album is None: | ||||
|                 try: | ||||
|                     current_album = self.get_album_by_path(current_location.absolute_path) | ||||
|                 except NoSuchAlbum: | ||||
|                 except exceptions.NoSuchAlbum: | ||||
|                     current_album = self.new_album( | ||||
|                         associated_directory=current_location.absolute_path, | ||||
|                         commit=False, | ||||
|  | @ -1213,13 +1020,13 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin): | |||
|                 parent = albums[current_location.parent.absolute_path] | ||||
|                 try: | ||||
|                     parent.add(current_album, commit=False) | ||||
|                 except GroupExists: | ||||
|                 except exceptions.GroupExists: | ||||
|                     pass | ||||
|                 #print('Added to %s' % parent.title) | ||||
|             for filepath in files: | ||||
|                 try: | ||||
|                     photo = self.new_photo(filepath.absolute_path, commit=False) | ||||
|                 except PhotoExists as e: | ||||
|                 except exceptions.PhotoExists as e: | ||||
|                     photo = e.photo | ||||
|                 current_album.add_photo(photo, commit=False) | ||||
| 
 | ||||
|  | @ -1259,7 +1066,7 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin): | |||
|             filepath = filepath.absolute_path | ||||
|             try: | ||||
|                 photo = self.get_photo_by_path(filepath) | ||||
|             except NoSuchPhoto: | ||||
|             except exceptions.NoSuchPhoto: | ||||
|                 pass | ||||
|             else: | ||||
|                 continue | ||||
|  | @ -1282,7 +1089,7 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin): | |||
|             try: | ||||
|                 item = self.get_tag(name) | ||||
|                 note = ('existing_tag', item.qualified_name()) | ||||
|             except NoSuchTag: | ||||
|             except exceptions.NoSuchTag: | ||||
|                 item = self.new_tag(name) | ||||
|                 note = ('new_tag', item.qualified_name()) | ||||
|             output_notes.append(note) | ||||
|  | @ -1330,7 +1137,7 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin): | |||
|                     lower.join_group(higher) | ||||
|                     note = ('join_group', '%s.%s' % (higher.name, lower.name)) | ||||
|                     output_notes.append(note) | ||||
|                 except GroupExists: | ||||
|                 except exceptions.GroupExists: | ||||
|                     pass | ||||
|             tag = tags[-1] | ||||
| 
 | ||||
|  | @ -1340,7 +1147,7 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin): | |||
|                 note = ('new_synonym', '%s+%s' % (tag.name, synonym)) | ||||
|                 output_notes.append(note) | ||||
|                 print('New syn %s' % synonym) | ||||
|             except TagExists: | ||||
|             except exceptions.TagExists: | ||||
|                 pass | ||||
|         return output_notes | ||||
| 
 | ||||
|  | @ -1405,19 +1212,19 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin): | |||
|             'album': | ||||
|             { | ||||
|                 'class': Album, | ||||
|                 'exception': NoSuchAlbum, | ||||
|                 'exception': exceptions.NoSuchAlbum, | ||||
|                 'table': 'albums', | ||||
|             }, | ||||
|             'tag': | ||||
|             { | ||||
|                 'class': Tag, | ||||
|                 'exception': NoSuchTag, | ||||
|                 'exception': exceptions.NoSuchTag, | ||||
|                 'table': 'tags', | ||||
|             }, | ||||
|             'photo': | ||||
|             { | ||||
|                 'class': Photo, | ||||
|                 'exception': NoSuchPhoto, | ||||
|                 'exception': exceptions.NoSuchPhoto, | ||||
|                 'table': 'photos', | ||||
|             }, | ||||
|         }[thing_type] | ||||
|  | @ -1447,7 +1254,7 @@ class GroupableMixin: | |||
|         ''' | ||||
|         Add a Tag object to this group. | ||||
| 
 | ||||
|         If that object is already a member of another group, a GroupExists is raised. | ||||
|         If that object is already a member of another group, a exceptions.GroupExists is raised. | ||||
|         ''' | ||||
|         if not isinstance(member, type(self)): | ||||
|             raise TypeError('Member must be of type %s' % type(self)) | ||||
|  | @ -1459,7 +1266,7 @@ class GroupableMixin: | |||
|                 that_group = self | ||||
|             else: | ||||
|                 that_group = self.group_getter(id=fetch[SQL_TAGGROUP['parentid']]) | ||||
|             raise GroupExists('%s already in group %s' % (member.name, that_group.name)) | ||||
|             raise exceptions.GroupExists('%s already in group %s' % (member.name, that_group.name)) | ||||
| 
 | ||||
|         self.photodb._cached_frozen_children = None | ||||
|         self.photodb.cur.execute('INSERT INTO tag_group_rel VALUES(?, ?)', [self.id, member.id]) | ||||
|  | @ -1740,7 +1547,7 @@ class Photo(ObjectBase): | |||
|         for tag in other_photo.tags(): | ||||
|             self.add_tag(tag) | ||||
| 
 | ||||
|     def delete(self, commit=True): | ||||
|     def delete(self, delete_file=False, commit=True): | ||||
|         ''' | ||||
|         Delete the Photo and its relation to any tags and albums. | ||||
|         ''' | ||||
|  | @ -1748,6 +1555,14 @@ class Photo(ObjectBase): | |||
|         self.photodb.cur.execute('DELETE FROM photos WHERE id == ?', [self.id]) | ||||
|         self.photodb.cur.execute('DELETE FROM photo_tag_rel WHERE photoid == ?', [self.id]) | ||||
|         self.photodb.cur.execute('DELETE FROM album_photo_rel WHERE photoid == ?', [self.id]) | ||||
| 
 | ||||
|         if delete_file: | ||||
|             path = self.real_path.absolute_path | ||||
|             if commit: | ||||
|                 os.remove(path) | ||||
|             else: | ||||
|                 queue_action = {'action': os.remove, 'args': [path]} | ||||
|                 self.photodb.on_commit_queue.append(queue_action) | ||||
|         if commit: | ||||
|             log.debug('Committing - delete photo') | ||||
|             self.photodb.commit() | ||||
|  | @ -1856,7 +1671,7 @@ class Photo(ObjectBase): | |||
|         return hopeful_filepath | ||||
| 
 | ||||
|     def mimetype(self): | ||||
|         return get_mimetype(self.real_filepath) | ||||
|         return helpers.get_mimetype(self.real_filepath) | ||||
| 
 | ||||
|     @decorators.time_me | ||||
|     def reload_metadata(self, commit=True): | ||||
|  | @ -1979,7 +1794,7 @@ class Photo(ObjectBase): | |||
|         else: | ||||
|             queue_action = {'action': os.remove, 'args': [old_path.absolute_path]} | ||||
|             self.photodb.on_commit_queue.append(queue_action) | ||||
|          | ||||
| 
 | ||||
|         self.__reinit__() | ||||
| 
 | ||||
|     def tags(self): | ||||
|  | @ -2036,11 +1851,11 @@ class Tag(ObjectBase, GroupableMixin): | |||
|             raise ValueError('Cannot assign synonym to itself.') | ||||
| 
 | ||||
|         try: | ||||
|             tag = self.photodb.get_tag_by_name(synname) | ||||
|         except NoSuchTag: | ||||
|             self.photodb.get_tag_by_name(synname) | ||||
|         except exceptions.NoSuchTag: | ||||
|             pass | ||||
|         else: | ||||
|             raise TagExists(synname) | ||||
|             raise exceptions.TagExists(synname) | ||||
| 
 | ||||
|         self.photodb._cached_frozen_children = None | ||||
|         self.photodb.cur.execute('INSERT INTO tag_synonyms VALUES(?, ?)', [synname, self.name]) | ||||
|  | @ -2103,11 +1918,11 @@ class Tag(ObjectBase, GroupableMixin): | |||
|         ''' | ||||
|         if self._cached_qualified_name: | ||||
|             return self._cached_qualified_name | ||||
|         string = self.name | ||||
|         qualname = self.name | ||||
|         for parent in self.walk_parents(): | ||||
|             string = parent.name + '.' + string | ||||
|         self._cached_qualified_name = string | ||||
|         return string | ||||
|             qualname = parent.name + '.' + qualname | ||||
|         self._cached_qualified_name = qualname | ||||
|         return qualname | ||||
| 
 | ||||
|     def remove_synonym(self, synname, commit=True): | ||||
|         ''' | ||||
|  | @ -2137,10 +1952,10 @@ class Tag(ObjectBase, GroupableMixin): | |||
| 
 | ||||
|         try: | ||||
|             self.photodb.get_tag(new_name) | ||||
|         except NoSuchTag: | ||||
|         except exceptions.NoSuchTag: | ||||
|             pass | ||||
|         else: | ||||
|             raise TagExists(new_name) | ||||
|             raise exceptions.TagExists(new_name) | ||||
| 
 | ||||
|         self._cached_qualified_name = None | ||||
|         self.photodb._cached_frozen_children = None | ||||
|  |  | |||
|  | @ -1,3 +1,5 @@ | |||
| flask | ||||
| gevent | ||||
| pillow | ||||
| pillow | ||||
| https://raw.githubusercontent.com/voussoir/else/master/_voussoirkit/voussoirkit.zip | ||||
| git+https://github.com/senko/python-video-converter.git | ||||
		Loading…
	
		Reference in a new issue