Coverage for rfpy/api/attachments.py: 100%
105 statements
« prev ^ index » next coverage.py v7.0.1, created at 2022-12-31 16:00 +0000
« prev ^ index » next coverage.py v7.0.1, created at 2022-12-31 16:00 +0000
1import pathlib
2from typing import BinaryIO
4import logging
5from datetime import datetime
6from rfpy.model.meta import AttachmentMixin
8from sqlalchemy.orm.session import Session
10from rfpy.model import (ProjectAttachment, IssueAttachment,
11 AAttachment, QAttachment)
13from rfpy import conf
16log = logging.getLogger(__name__)
19class OrphanedAttachmentError(ValueError):
20 pass
23def get_sub_path(att) -> str:
25 if not isinstance(att.id, int):
26 raise ValueError('Can only determine path for attachments with an ID')
28 if isinstance(att, ProjectAttachment):
29 return f'{att.project.id}/PROJECT/{att.id}'
31 if isinstance(att, IssueAttachment):
32 return f'{att.issue.project.id}/ISSUE/{att.id}'
34 if isinstance(att, QAttachment):
35 return f'QUESTION_ATTACHMENTS/{att.id}'
37 if isinstance(att, AAttachment):
38 if att.answer is None:
39 m = f'answer attachment # {att.id} is an orphan- no Answer found'
40 raise OrphanedAttachmentError(m)
41 return f'{att.answer.issue.project_id}/ANSWER/{att.id}'
43 raise ValueError(f'Attachment {att} is not a recognised type')
46def local_to_full(local_path) -> pathlib.Path:
47 atts_dir = root_attachments_dir()
48 return atts_dir.joinpath(local_path)
51def root_attachments_dir() -> pathlib.Path:
52 return pathlib.Path(conf.CONF.attachments_dir)
55def get_full_path(attachment) -> pathlib.Path:
56 '''pathlib.Path object for the attachment'''
57 sub_path = get_sub_path(attachment)
58 return local_to_full(sub_path)
61def save_to_disc(attachment, readable_object: BinaryIO, overwrite=False):
62 '''
63 Handles saving a physical attachment file associated with an attachment
64 object/ record (e.g. a Project Attachment) to disc.
65 '''
67 if not hasattr(readable_object, 'read'):
68 raise ValueError('readable_object must have a read() method')
70 fpath = get_full_path(attachment)
72 if fpath.exists() and not overwrite:
73 raise IOError('{} already exists, will not overwrite'.format(fpath))
75 if not fpath.parent.exists():
76 fpath.parent.mkdir(parents=True)
78 bytes_written = fpath.write_bytes(readable_object.read())
80 attachment.size_bytes = bytes_written
83def delete_from_disc(attachment):
84 fpath = get_full_path(attachment)
85 fpath.unlink()
86 log.info("Deleted attachment %s from filepath %s",
87 attachment.filename, fpath)
88 return fpath
91def find_orphan_answer_attachment(root_dir: pathlib.Path, attachment_id):
92 try:
93 return list(root_dir.glob(f'*/ANSWER/{attachment_id}'))[0]
94 except IndexError:
95 raise FileNotFoundError(f"Couldn't find attachment id {attachment_id}")
98def delete_orphan_answer_attachments(session, dry_run=True, prune_db=False):
99 '''
100 Find and delete answer attachment files whose parent answer record has
101 been deleted, leaving them orphan.
102 If dry_run is True, just identify and return the files that would be chopped
103 If prune_db is True, also delete the records from the database.
105 Returns a tuple:
106 (deleted, missing, deleted_row_count)
108 First two values are a Set of pathlib.Path objects
109 '''
110 root = root_attachments_dir()
111 deleted_filenames = set()
112 missing_filenames = set()
113 deleted_row_count = 0
115 for a in session.query(AAttachment.id)\
116 .filter(AAttachment.answer_id.is_(None)):
117 try:
118 att_filename = find_orphan_answer_attachment(root, a.id)
119 if not dry_run:
120 att_filename.unlink()
121 deleted_filenames.add(att_filename)
122 except FileNotFoundError:
123 missing_filenames.add(f"/ANSWER/{a.id}")
124 if prune_db and not dry_run:
125 session.commit()
126 rc = session.query(AAttachment)\
127 .filter(AAttachment.answer_id.is_(None))\
128 .delete()
129 deleted_row_count = rc
131 return (deleted_filenames, missing_filenames, deleted_row_count)
134def save_issue_attachment(session, issue_id, user, cgi_file,
135 description):
137 ia = IssueAttachment()
138 ia.filename = cgi_file.filename
139 ia.guess_set_mimetype(cgi_file.filename)
140 ia.date_uploaded = datetime.now()
141 ia.author_id = user.id
142 ia.org_id = user.org_id
143 ia.description = description
145 # Necessary to set size to non-null value for Mysql to save the record
146 ia.size_bytes = 0
148 ia.issue_id = issue_id
150 session.add(ia)
151 session.flush()
153 save_to_disc(ia, cgi_file.file)
155 return ia
158def save_project_attachment(session, project_id, user, cgi_file, description):
160 pa = ProjectAttachment()
161 pa.filename = cgi_file.filename
162 pa.guess_set_mimetype(cgi_file.filename)
163 pa.date_uploaded = datetime.now()
164 pa.author_id = user.id
165 pa.org_id = user.org_id
166 pa.description = description
168 # Necessary to set size to non-null value for Mysql to save the record
169 pa.size_bytes = 0
171 pa.project_id = project_id
173 session.add(pa)
174 session.flush()
176 save_to_disc(pa, cgi_file.file)
178 return pa
181def delete_project_attachment(session: Session, attachment: AttachmentMixin):
182 fpath = delete_from_disc(attachment)
183 session.delete(attachment)
184 session.flush()
185 return fpath