Coverage for rfpy/api/attachments.py: 100%
105 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-24 10:52 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-24 10:52 +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, AAttachment, QAttachment
12from rfpy import conf
15log = logging.getLogger(__name__)
18class OrphanedAttachmentError(ValueError):
19 pass
22def get_sub_path(att) -> str:
23 if not isinstance(att.id, int):
24 raise ValueError("Can only determine path for attachments with an ID")
26 if isinstance(att, ProjectAttachment):
27 return f"{att.project.id}/PROJECT/{att.id}"
29 if isinstance(att, IssueAttachment):
30 return f"{att.issue.project.id}/ISSUE/{att.id}"
32 if isinstance(att, QAttachment):
33 return f"QUESTION_ATTACHMENTS/{att.id}"
35 if isinstance(att, AAttachment):
36 if att.answer is None:
37 m = f"answer attachment # {att.id} is an orphan- no Answer found"
38 raise OrphanedAttachmentError(m)
39 return f"{att.answer.issue.project_id}/ANSWER/{att.id}"
41 raise ValueError(f"Attachment {att} is not a recognised type")
44def local_to_full(local_path) -> pathlib.Path:
45 atts_dir = root_attachments_dir()
46 return atts_dir.joinpath(local_path)
49def root_attachments_dir() -> pathlib.Path:
50 return pathlib.Path(conf.CONF.attachments_dir)
53def get_full_path(attachment) -> pathlib.Path:
54 """pathlib.Path object for the attachment"""
55 sub_path = get_sub_path(attachment)
56 return local_to_full(sub_path)
59def save_to_disc(attachment, readable_object: BinaryIO, overwrite=False):
60 """
61 Handles saving a physical attachment file associated with an attachment
62 object/ record (e.g. a Project Attachment) to disc.
63 """
65 if not hasattr(readable_object, "read"):
66 raise ValueError("readable_object must have a read() method")
68 fpath = get_full_path(attachment)
70 if fpath.exists() and not overwrite:
71 raise IOError("{} already exists, will not overwrite".format(fpath))
73 if not fpath.parent.exists():
74 fpath.parent.mkdir(parents=True)
76 bytes_written = fpath.write_bytes(readable_object.read())
78 attachment.size_bytes = bytes_written
81def delete_from_disc(attachment):
82 fpath = get_full_path(attachment)
83 fpath.unlink()
84 log.info("Deleted attachment %s from filepath %s", attachment.filename, fpath)
85 return fpath
88def find_orphan_answer_attachment(root_dir: pathlib.Path, attachment_id):
89 try:
90 return list(root_dir.glob(f"*/ANSWER/{attachment_id}"))[0]
91 except IndexError:
92 raise FileNotFoundError(f"Couldn't find attachment id {attachment_id}")
95def delete_orphan_answer_attachments(session, dry_run=True, prune_db=False):
96 """
97 Find and delete answer attachment files whose parent answer record has
98 been deleted, leaving them orphan.
99 If dry_run is True, just identify and return the files that would be chopped
100 If prune_db is True, also delete the records from the database.
102 Returns a tuple:
103 (deleted, missing, deleted_row_count)
105 First two values are a Set of pathlib.Path objects
106 """
107 root = root_attachments_dir()
108 deleted_filenames = set()
109 missing_filenames = set()
110 deleted_row_count = 0
112 for a in session.query(AAttachment.id).filter(AAttachment.answer_id.is_(None)):
113 try:
114 att_filename = find_orphan_answer_attachment(root, a.id)
115 if not dry_run:
116 att_filename.unlink()
117 deleted_filenames.add(att_filename)
118 except FileNotFoundError:
119 missing_filenames.add(f"/ANSWER/{a.id}")
120 if prune_db and not dry_run:
121 session.commit()
122 rc = session.query(AAttachment).filter(AAttachment.answer_id.is_(None)).delete()
123 deleted_row_count = rc
125 return (deleted_filenames, missing_filenames, deleted_row_count)
128def save_issue_attachment(session, issue_id, user, cgi_file, description):
129 ia = IssueAttachment()
130 ia.filename = cgi_file.filename
131 ia.guess_set_mimetype(cgi_file.filename)
132 ia.date_uploaded = datetime.now()
133 ia.author_id = user.id
134 ia.org_id = user.org_id
135 ia.description = description
137 # Necessary to set size to non-null value for Mysql to save the record
138 ia.size_bytes = 0
140 ia.issue_id = issue_id
142 session.add(ia)
143 session.flush()
145 save_to_disc(ia, cgi_file.file)
147 return ia
150def save_project_attachment(session, project_id, user, cgi_file, description):
151 pa = ProjectAttachment()
152 pa.filename = cgi_file.filename
153 pa.guess_set_mimetype(cgi_file.filename)
154 pa.date_uploaded = datetime.now()
155 pa.author_id = user.id
156 pa.org_id = user.org_id
157 pa.description = description
159 # Necessary to set size to non-null value for Mysql to save the record
160 pa.size_bytes = 0
162 pa.project_id = project_id
164 session.add(pa)
165 session.flush()
167 save_to_disc(pa, cgi_file.file)
169 return pa
172def delete_project_attachment(session: Session, attachment: AttachmentMixin):
173 fpath = delete_from_disc(attachment)
174 session.delete(attachment)
175 session.flush()
176 return fpath