Merge ~andrey-fedoseev/launchpad:bug-attachment-url into launchpad:master

Proposed by Andrey Fedoseev
Status: Merged
Approved by: Andrey Fedoseev
Approved revision: e33a250e8386acd41143f94f514d8757ce516a90
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~andrey-fedoseev/launchpad:bug-attachment-url
Merge into: launchpad:master
Diff against target: 1734 lines (+424/-216)
38 files modified
lib/canonical/launchpad/icing/css/components/sidebar_portlets.css (+6/-0)
lib/lp/app/browser/configure.zcml (+6/-0)
lib/lp/app/browser/tales.py (+38/-1)
lib/lp/bugs/adapters/bugchange.py (+4/-10)
lib/lp/bugs/browser/bug.py (+11/-21)
lib/lp/bugs/browser/bugattachment.py (+37/-28)
lib/lp/bugs/browser/bugcomment.py (+0/-7)
lib/lp/bugs/browser/bugmessage.py (+39/-12)
lib/lp/bugs/browser/bugtarget.py (+2/-5)
lib/lp/bugs/browser/tests/bug-views.rst (+4/-4)
lib/lp/bugs/browser/tests/test_bug_views.py (+19/-0)
lib/lp/bugs/browser/tests/test_bugview.py (+2/-8)
lib/lp/bugs/doc/bug-export.rst (+23/-5)
lib/lp/bugs/doc/bug.rst (+1/-0)
lib/lp/bugs/doc/bugattachments.rst (+13/-1)
lib/lp/bugs/doc/bugcomment.rst (+6/-0)
lib/lp/bugs/interfaces/bug.py (+9/-4)
lib/lp/bugs/interfaces/bugattachment.py (+15/-3)
lib/lp/bugs/interfaces/bugmessage.py (+2/-1)
lib/lp/bugs/mail/handler.py (+1/-0)
lib/lp/bugs/model/bug.py (+63/-37)
lib/lp/bugs/model/bugattachment.py (+36/-4)
lib/lp/bugs/model/tests/test_bugtasksearch.py (+2/-0)
lib/lp/bugs/scripts/bugexport.py (+20/-18)
lib/lp/bugs/scripts/bugimport.py (+1/-0)
lib/lp/bugs/scripts/tests/test_bugnotification.py (+1/-1)
lib/lp/bugs/stories/bugattachments/xx-attachments-to-bug-report.rst (+2/-0)
lib/lp/bugs/stories/bugs/xx-bug-text-pages.rst (+3/-0)
lib/lp/bugs/stories/bugtask-searches/xx-listing-basics.rst (+1/-0)
lib/lp/bugs/stories/patches-view/patches-view.rst (+12/-14)
lib/lp/bugs/stories/webservice/xx-bug.rst (+3/-0)
lib/lp/bugs/templates/bug-attachment-macros.pt (+6/-0)
lib/lp/bugs/templates/bug-portlet-attachments.pt (+8/-16)
lib/lp/bugs/templates/bugcomment-box.pt (+6/-5)
lib/lp/bugs/templates/bugtarget-patches.pt (+3/-4)
lib/lp/bugs/tests/bugs-emailinterface.rst (+1/-1)
lib/lp/scripts/tests/test_garbo.py (+4/-2)
lib/lp/testing/factory.py (+14/-4)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+430983@code.launchpad.net

Commit message

Add `url` to `BugAttachment`

Description of the change

`libraryfile` field of `BugAttachment` is now optional, an attachment can either contain a file, or have a URL

Add `displayed_url` property to `BugAttachment` that contains either a proxied attachment file URL, or the URL associated with the attachment.

Improve the `BugAttachment` links:

- Use the formatter API to create the links
- Use different icons depending on the link type: external URL, a patch, a downloadable file
- Add rel=nofollow to external URLs

To post a comment you must log in.
Revision history for this message
Andrey Fedoseev (andrey-fedoseev) wrote :
Revision history for this message
Andrey Fedoseev (andrey-fedoseev) wrote :
Revision history for this message
Colin Watson (cjwatson) wrote :

I like the use of the formatter API and the new `displayed_url` property.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/canonical/launchpad/icing/css/components/sidebar_portlets.css b/lib/canonical/launchpad/icing/css/components/sidebar_portlets.css
2index e4f4ff3..37487d2 100644
3--- a/lib/canonical/launchpad/icing/css/components/sidebar_portlets.css
4+++ b/lib/canonical/launchpad/icing/css/components/sidebar_portlets.css
5@@ -132,3 +132,9 @@
6 padding-right: 0.25em;
7 }
8
9+/******************************************************************************
10+ Attachments portlet
11+*/
12+.side .portlet .download-attachment {
13+ word-break: break-word;
14+ }
15diff --git a/lib/lp/app/browser/configure.zcml b/lib/lp/app/browser/configure.zcml
16index 88985a9..cc40021 100644
17--- a/lib/lp/app/browser/configure.zcml
18+++ b/lib/lp/app/browser/configure.zcml
19@@ -932,6 +932,12 @@
20 name="fmt"
21 />
22 <adapter
23+ for="lp.bugs.interfaces.bugattachment.IBugAttachment"
24+ provides="zope.traversing.interfaces.IPathAdapter"
25+ factory="lp.app.browser.tales.BugAttachmentFormatterAPI"
26+ name="fmt"
27+ />
28+ <adapter
29 for="lp.services.webapp.interfaces.ILink"
30 provides="zope.traversing.interfaces.IPathAdapter"
31 factory="lp.app.browser.tales.LinkFormatterAPI"
32diff --git a/lib/lp/app/browser/tales.py b/lib/lp/app/browser/tales.py
33index a413347..89a29bc 100644
34--- a/lib/lp/app/browser/tales.py
35+++ b/lib/lp/app/browser/tales.py
36@@ -44,6 +44,7 @@ from lp.app.interfaces.launchpad import (
37 from lp.blueprints.interfaces.specification import ISpecification
38 from lp.blueprints.interfaces.sprint import ISprint
39 from lp.bugs.interfaces.bug import IBug
40+from lp.bugs.interfaces.bugattachment import IBugAttachment
41 from lp.buildmaster.enums import BuildStatus
42 from lp.code.enums import RevisionStatusResult
43 from lp.code.interfaces.branch import IBranch
44@@ -726,7 +727,6 @@ class ObjectImageDisplayAPI:
45 def __init__(self, context):
46 self._context = context
47
48- # def default_icon_resource(self, context):
49 def sprite_css(self):
50 """Return the CSS class for the sprite"""
51 # XXX: mars 2008-08-22 bug=260468
52@@ -754,6 +754,13 @@ class ObjectImageDisplayAPI:
53 sprite_string = "meeting"
54 elif IBug.providedBy(context):
55 sprite_string = "bug"
56+ elif IBugAttachment.providedBy(context):
57+ if context.url:
58+ sprite_string = "external-link"
59+ elif context.is_patch:
60+ sprite_string = "haspatch-icon"
61+ else:
62+ sprite_string = "download-icon"
63 elif IPPA.providedBy(context):
64 if context.enabled:
65 sprite_string = "ppa-icon"
66@@ -1866,6 +1873,36 @@ class BugTaskFormatterAPI(CustomizableFormatter):
67 return BugFormatterAPI(self._context.bug)._make_link_summary()
68
69
70+class BugAttachmentFormatterAPI(CustomizableFormatter):
71+
72+ final_traversable_names = dict(
73+ **CustomizableFormatter.final_traversable_names,
74+ attachment_link="attachment_link",
75+ )
76+
77+ def attachment_link(self):
78+ sprite = self.sprite_css()
79+ if sprite is None:
80+ css = ""
81+ else:
82+ css = ' class="' + sprite + '"'
83+
84+ if self._context.url:
85+ rel = ' rel="nofollow"'
86+ else:
87+ rel = ""
88+
89+ title = structured(self._context.title).escapedtext
90+ url = structured(self._context.displayed_url).escapedtext
91+
92+ return """<a href="{url}"{css}{rel}>{title}</a>""".format(
93+ title=title,
94+ url=url,
95+ css=css,
96+ rel=rel,
97+ )
98+
99+
100 class CodeImportFormatterAPI(CustomizableFormatter):
101 """Adapter providing fmt support for CodeImport objects"""
102
103diff --git a/lib/lp/bugs/adapters/bugchange.py b/lib/lp/bugs/adapters/bugchange.py
104index c7babdc..e97d6e8 100644
105--- a/lib/lp/bugs/adapters/bugchange.py
106+++ b/lib/lp/bugs/adapters/bugchange.py
107@@ -63,7 +63,6 @@ from lp.bugs.interfaces.bugtask import (
108 IBugTask,
109 )
110 from lp.registry.interfaces.product import IProduct
111-from lp.services.librarian.browser import ProxiedLibraryFileAlias
112 from lp.services.webapp.publisher import canonical_url
113
114 # These are used lp.bugs.model.bugactivity.BugActivity.attribute to normalize
115@@ -698,11 +697,6 @@ class BugTagsChange(AttributeChange):
116 return {"text": "\n".join(messages)}
117
118
119-def download_url_of_bugattachment(attachment):
120- """Return the URL of the ProxiedLibraryFileAlias for the attachment."""
121- return ProxiedLibraryFileAlias(attachment.libraryfile, attachment).http_url
122-
123-
124 class BugAttachmentChange(AttributeChange):
125 """Used to represent a change to an `IBug`'s attachments."""
126
127@@ -712,13 +706,13 @@ class BugAttachmentChange(AttributeChange):
128 old_value = None
129 new_value = "%s %s" % (
130 self.new_value.title,
131- download_url_of_bugattachment(self.new_value),
132+ self.new_value.displayed_url,
133 )
134 else:
135 what_changed = ATTACHMENT_REMOVED
136 old_value = "%s %s" % (
137 self.old_value.title,
138- download_url_of_bugattachment(self.old_value),
139+ self.old_value.displayed_url,
140 )
141 new_value = None
142
143@@ -737,7 +731,7 @@ class BugAttachmentChange(AttributeChange):
144 message = '** %s added: "%s"\n %s' % (
145 attachment_str,
146 self.new_value.title,
147- download_url_of_bugattachment(self.new_value),
148+ self.new_value.displayed_url,
149 )
150 else:
151 if self.old_value.is_patch:
152@@ -747,7 +741,7 @@ class BugAttachmentChange(AttributeChange):
153 message = '** %s removed: "%s"\n %s' % (
154 attachment_str,
155 self.old_value.title,
156- download_url_of_bugattachment(self.old_value),
157+ self.old_value.displayed_url,
158 )
159
160 return {"text": message}
161diff --git a/lib/lp/bugs/browser/bug.py b/lib/lp/bugs/browser/bug.py
162index 3a675a8..717f39c 100644
163--- a/lib/lp/bugs/browser/bug.py
164+++ b/lib/lp/bugs/browser/bug.py
165@@ -85,7 +85,6 @@ from lp.registry.interfaces.person import IPersonSet
166 from lp.services.compat import message_as_bytes
167 from lp.services.features import getFeatureFlag
168 from lp.services.fields import DuplicateBug
169-from lp.services.librarian.browser import ProxiedLibraryFileAlias
170 from lp.services.mail.mailwrapper import MailWrapper
171 from lp.services.propertycache import cachedproperty
172 from lp.services.searchbuilder import any, not_equals
173@@ -534,17 +533,11 @@ class BugViewMixin:
174 "other": [],
175 }
176 for attachment in self.context.attachments_unpopulated:
177- info = {
178- "attachment": attachment,
179- "file": ProxiedLibraryFileAlias(
180- attachment.libraryfile, attachment
181- ),
182- }
183 if attachment.type == BugAttachmentType.PATCH:
184 key = attachment.type
185 else:
186 key = "other"
187- result[key].append(info)
188+ result[key].append(attachment)
189 return result
190
191 @property
192@@ -636,12 +629,6 @@ class BugView(LaunchpadView, BugViewMixin):
193
194 return dupes
195
196- def proxiedUrlForLibraryFile(self, attachment):
197- """Return the proxied download URL for a Librarian file."""
198- return ProxiedLibraryFileAlias(
199- attachment.libraryfile, attachment
200- ).http_url
201-
202
203 class BugActivity(BugView):
204
205@@ -1304,13 +1291,16 @@ class BugTextView(LaunchpadView):
206
207 def attachment_text(self, attachment):
208 """Return a text representation of a bug attachment."""
209- mime_type = normalize_mime_type.sub(
210- " ", attachment.libraryfile.mimetype
211- )
212- download_url = ProxiedLibraryFileAlias(
213- attachment.libraryfile, attachment
214- ).http_url
215- return "%s %s" % (download_url, mime_type)
216+ if attachment.url:
217+ if attachment.title != attachment.url:
218+ return "{}: {}".format(attachment.title, attachment.url)
219+ return attachment.url
220+ elif attachment.libraryfile:
221+ mime_type = normalize_mime_type.sub(
222+ " ", attachment.libraryfile.mimetype
223+ )
224+ return "{} {}".format(attachment.displayed_url, mime_type)
225+ raise AssertionError()
226
227 def comment_text(self):
228 """Return a text representation of bug comments."""
229diff --git a/lib/lp/bugs/browser/bugattachment.py b/lib/lp/bugs/browser/bugattachment.py
230index 7d45e45..ce004b8 100644
231--- a/lib/lp/bugs/browser/bugattachment.py
232+++ b/lib/lp/bugs/browser/bugattachment.py
233@@ -25,10 +25,7 @@ from lp.bugs.interfaces.bugattachment import (
234 IBugAttachmentIsPatchConfirmationForm,
235 IBugAttachmentSet,
236 )
237-from lp.services.librarian.browser import (
238- FileNavigationMixin,
239- ProxiedLibraryFileAlias,
240-)
241+from lp.services.librarian.browser import FileNavigationMixin
242 from lp.services.librarian.interfaces import ILibraryFileAliasWithParent
243 from lp.services.webapp import GetitemNavigation, Navigation, canonical_url
244 from lp.services.webapp.authorization import check_permission
245@@ -115,15 +112,22 @@ class BugAttachmentEditView(LaunchpadFormView, BugAttachmentContentCheck):
246 self.next_url = self.cancel_url = canonical_url(
247 ICanonicalUrlData(context).inside
248 )
249+ if not context.libraryfile:
250+ # Remove `contenttype` from the form if there's no file attached.
251+ self.field_names = [
252+ name for name in self.field_names if name != "contenttype"
253+ ]
254
255 @property
256 def initial_values(self):
257 attachment = self.context
258- return dict(
259+ values = dict(
260 title=attachment.title,
261 patch=attachment.type == BugAttachmentType.PATCH,
262- contenttype=attachment.libraryfile.mimetype,
263 )
264+ if attachment.libraryfile:
265+ values["contenttype"] = attachment.libraryfile.mimetype
266+ return values
267
268 def canEditAttachment(self, action):
269 return check_permission("launchpad.Edit", self.context)
270@@ -135,30 +139,38 @@ class BugAttachmentEditView(LaunchpadFormView, BugAttachmentContentCheck):
271 else:
272 new_type = BugAttachmentType.UNSPECIFIED
273 if new_type != self.context.type:
274- filename = self.context.libraryfile.filename
275- file_content = self.context.libraryfile.read()
276- # We expect that users set data['patch'] to True only for
277- # real patch data, indicated by guessed_content_type ==
278- # 'text/x-diff'. If there are inconsistencies, we don't
279- # set the value automatically. Instead, we lead the user to
280- # another form where we ask them if they are sure about their
281- # choice of the patch flag.
282- new_type_consistent_with_guessed_type = (
283- self.attachmentTypeConsistentWithContentType(
284- new_type == BugAttachmentType.PATCH, filename, file_content
285+ if self.context.libraryfile:
286+ filename = self.context.libraryfile.filename
287+ file_content = self.context.libraryfile.read()
288+ # We expect that users set data['patch'] to True only for
289+ # real patch data, indicated by guessed_content_type ==
290+ # 'text/x-diff'. If there are inconsistencies, we don't
291+ # set the value automatically. Instead, we lead the user to
292+ # another form where we ask them if they are sure about their
293+ # choice of the patch flag.
294+ new_type_consistent_with_guessed_type = (
295+ self.attachmentTypeConsistentWithContentType(
296+ new_type == BugAttachmentType.PATCH,
297+ filename,
298+ file_content,
299+ )
300 )
301- )
302- if new_type_consistent_with_guessed_type:
303- self.context.type = new_type
304+ if new_type_consistent_with_guessed_type:
305+ self.context.type = new_type
306+ else:
307+ self.next_url = self.nextUrlForInconsistentPatchFlags(
308+ self.context
309+ )
310 else:
311- self.next_url = self.nextUrlForInconsistentPatchFlags(
312- self.context
313- )
314+ self.context.type = new_type
315
316 if data["title"] != self.context.title:
317 self.context.title = data["title"]
318
319- if self.context.libraryfile.mimetype != data["contenttype"]:
320+ if (
321+ self.context.libraryfile
322+ and self.context.libraryfile.mimetype != data["contenttype"]
323+ ):
324 lfa_with_parent = getMultiAdapter(
325 (self.context.libraryfile, self.context),
326 ILibraryFileAliasWithParent,
327@@ -167,14 +179,11 @@ class BugAttachmentEditView(LaunchpadFormView, BugAttachmentContentCheck):
328
329 @action("Delete Attachment", name="delete", condition=canEditAttachment)
330 def delete_action(self, action, data):
331- libraryfile_url = ProxiedLibraryFileAlias(
332- self.context.libraryfile, self.context
333- ).http_url
334 self.request.response.addInfoNotification(
335 structured(
336 'Attachment "<a href="%(url)s">%(name)s</a>" has been '
337 "deleted.",
338- url=libraryfile_url,
339+ url=self.context.displayed_url,
340 name=self.context.title,
341 )
342 )
343diff --git a/lib/lp/bugs/browser/bugcomment.py b/lib/lp/bugs/browser/bugcomment.py
344index 6a32ff1..0f653f6 100644
345--- a/lib/lp/bugs/browser/bugcomment.py
346+++ b/lib/lp/bugs/browser/bugcomment.py
347@@ -29,7 +29,6 @@ from lp.bugs.interfaces.bugmessage import IBugComment
348 from lp.services.comments.browser.comment import download_body
349 from lp.services.comments.browser.messagecomment import MessageComment
350 from lp.services.config import config
351-from lp.services.librarian.browser import ProxiedLibraryFileAlias
352 from lp.services.messages.interfaces.message import IMessage
353 from lp.services.propertycache import cachedproperty, get_property_cache
354 from lp.services.webapp import (
355@@ -378,12 +377,6 @@ class BugCommentBoxViewMixin:
356 else:
357 return False
358
359- def proxiedUrlOfLibraryFileAlias(self, attachment):
360- """Return the proxied URL for the Librarian file of the attachment."""
361- return ProxiedLibraryFileAlias(
362- attachment.libraryfile, attachment
363- ).http_url
364-
365
366 class BugCommentBoxView(LaunchpadView, BugCommentBoxViewMixin):
367 """Render a comment box with reply field collapsed."""
368diff --git a/lib/lp/bugs/browser/bugmessage.py b/lib/lp/bugs/browser/bugmessage.py
369index b0267a1..30cff9e 100644
370--- a/lib/lp/bugs/browser/bugmessage.py
371+++ b/lib/lp/bugs/browser/bugmessage.py
372@@ -10,6 +10,7 @@ __all__ = [
373 from io import BytesIO
374
375 from zope.component import getUtility
376+from zope.formlib.textwidgets import TextWidget
377 from zope.formlib.widget import CustomWidgetFactory
378 from zope.formlib.widgets import TextAreaWidget
379
380@@ -29,6 +30,9 @@ class BugMessageAddFormView(LaunchpadFormView, BugAttachmentContentCheck):
381 custom_widget_comment = CustomWidgetFactory(
382 TextAreaWidget, cssClass="comment-text"
383 )
384+ custom_widget_attachment_url = CustomWidgetFactory(
385+ TextWidget, displayWidth=44, displayMaxWidth=250
386+ )
387
388 page_title = "Add a comment or attachment"
389
390@@ -49,14 +53,19 @@ class BugMessageAddFormView(LaunchpadFormView, BugAttachmentContentCheck):
391
392 def validate(self, data):
393
394- # Ensure either a comment or filecontent was provide, but only
395- # if no errors have already been noted.
396+ # Ensure either a comment or filecontent or a URL was provided,
397+ # but only if no errors have already been noted.
398 if len(self.errors) == 0:
399 comment = data.get("comment") or ""
400 filecontent = data.get("filecontent", None)
401- if not comment.strip() and not filecontent:
402+ attachment_url = data.get("attachment_url") or ""
403+ if (
404+ not comment.strip()
405+ and not filecontent
406+ and not attachment_url.strip()
407+ ):
408 self.addError(
409- "Either a comment or attachment " "must be provided."
410+ "Either a comment or attachment must be provided."
411 )
412
413 @action("Post Comment", name="save")
414@@ -73,8 +82,10 @@ class BugMessageAddFormView(LaunchpadFormView, BugAttachmentContentCheck):
415 # Write proper FileUpload field and widget instead of this hack.
416 file_ = self.request.form.get(self.widgets["filecontent"].name)
417
418+ attachment_url = data.get("attachment_url")
419+
420 message = None
421- if data["comment"] or file_:
422+ if data["comment"] or file_ or attachment_url:
423 bugwatch_id = data.get("bugwatch_id")
424 if bugwatch_id is not None:
425 bugwatch = getUtility(IBugWatchSet).get(bugwatch_id)
426@@ -87,7 +98,7 @@ class BugMessageAddFormView(LaunchpadFormView, BugAttachmentContentCheck):
427 bugwatch=bugwatch,
428 )
429
430- # A blank comment with only a subect line is always added
431+ # A blank comment with only a subject line is always added
432 # when the user attaches a file, so show the add comment
433 # feedback message only when the user actually added a
434 # comment.
435@@ -97,6 +108,9 @@ class BugMessageAddFormView(LaunchpadFormView, BugAttachmentContentCheck):
436 )
437
438 self.next_url = canonical_url(self.context)
439+
440+ attachment_description = data.get("attachment_description")
441+
442 if file_:
443
444 # Slashes in filenames cause problems, convert them to dashes
445@@ -104,11 +118,8 @@ class BugMessageAddFormView(LaunchpadFormView, BugAttachmentContentCheck):
446 filename = file_.filename.replace("/", "-")
447
448 # if no description was given use the converted filename
449- file_description = None
450- if "attachment_description" in data:
451- file_description = data["attachment_description"]
452- if not file_description:
453- file_description = filename
454+ if not attachment_description:
455+ attachment_description = filename
456
457 # Process the attachment.
458 # If the patch flag is not consistent with the result of
459@@ -132,7 +143,8 @@ class BugMessageAddFormView(LaunchpadFormView, BugAttachmentContentCheck):
460 owner=self.user,
461 data=BytesIO(data["filecontent"]),
462 filename=filename,
463- description=file_description,
464+ url=None,
465+ description=attachment_description,
466 comment=message,
467 is_patch=is_patch,
468 )
469@@ -146,6 +158,21 @@ class BugMessageAddFormView(LaunchpadFormView, BugAttachmentContentCheck):
470 "Attachment %s added to bug." % filename
471 )
472
473+ elif attachment_url:
474+ is_patch = data["patch"]
475+ bug.addAttachment(
476+ owner=self.user,
477+ data=None,
478+ filename=None,
479+ url=attachment_url,
480+ description=attachment_description,
481+ comment=message,
482+ is_patch=is_patch,
483+ )
484+ self.request.response.addNotification(
485+ "Attachment %s added to bug." % attachment_url
486+ )
487+
488 def shouldShowEmailMeWidget(self):
489 """Should the subscribe checkbox be shown?"""
490 return not self.context.bug.isSubscribed(self.user)
491diff --git a/lib/lp/bugs/browser/bugtarget.py b/lib/lp/bugs/browser/bugtarget.py
492index e7b3dc6..43e084f 100644
493--- a/lib/lp/bugs/browser/bugtarget.py
494+++ b/lib/lp/bugs/browser/bugtarget.py
495@@ -117,7 +117,6 @@ from lp.registry.vocabularies import ValidPersonOrTeamVocabulary
496 from lp.services.config import config
497 from lp.services.features import getFeatureFlag
498 from lp.services.job.interfaces.job import JobStatus
499-from lp.services.librarian.browser import ProxiedLibraryFileAlias
500 from lp.services.propertycache import cachedproperty
501 from lp.services.webapp import LaunchpadView, canonical_url, urlappend
502 from lp.services.webapp.authorization import check_permission
503@@ -673,6 +672,7 @@ class FileBugViewBase(LaunchpadFormView):
504 owner=self.user,
505 data=BytesIO(data["filecontent"]),
506 filename=filename,
507+ url=None,
508 description=file_description,
509 comment=attachment_comment,
510 is_patch=data["patch"],
511@@ -686,6 +686,7 @@ class FileBugViewBase(LaunchpadFormView):
512 bug.linkAttachment(
513 owner=self.user,
514 file_alias=attachment["file_alias"],
515+ url=None,
516 description=attachment["description"],
517 comment=attachment_comment,
518 send_notifications=False,
519@@ -1472,10 +1473,6 @@ class BugsPatchesView(LaunchpadView):
520 now = datetime.now(timezone("UTC"))
521 return now - patch.message.datecreated
522
523- def proxiedUrlForLibraryFile(self, patch):
524- """Return the proxied download URL for a Librarian file."""
525- return ProxiedLibraryFileAlias(patch.libraryfile, patch).http_url
526-
527
528 class TargetSubscriptionView(LaunchpadView):
529 """A view to show all a person's structural subscriptions to a target."""
530diff --git a/lib/lp/bugs/browser/tests/bug-views.rst b/lib/lp/bugs/browser/tests/bug-views.rst
531index 74a9fd7..008659a 100644
532--- a/lib/lp/bugs/browser/tests/bug-views.rst
533+++ b/lib/lp/bugs/browser/tests/bug-views.rst
534@@ -334,22 +334,22 @@ librarian file of the attachment.
535 ... )
536 >>> view = BugView(bug_seven, request)
537 >>> for attachment in view.regular_attachments:
538- ... print(attachment["attachment"].title)
539+ ... print(attachment.title)
540 ...
541 attachment 1
542 attachment 2
543 >>> for patch in view.patches:
544- ... print(patch["attachment"].title)
545+ ... print(patch.title)
546 ...
547 patch 1
548 patch 2
549 >>> for attachment in view.regular_attachments:
550- ... print(attachment["file"].http_url)
551+ ... print(attachment.displayed_url)
552 ...
553 http://bugs.launchpad.test/firefox/+bug/5/+attachment/.../+files/a1
554 http://bugs.launchpad.test/firefox/+bug/5/+attachment/.../+files/a2
555 >>> for patch in view.patches:
556- ... print(patch["file"].http_url)
557+ ... print(patch.displayed_url)
558 ...
559 http://bugs.launchpad.test/firefox/+bug/5/+attachment/.../+files/p1
560 http://bugs.launchpad.test/firefox/+bug/5/+attachment/.../+files/p2
561diff --git a/lib/lp/bugs/browser/tests/test_bug_views.py b/lib/lp/bugs/browser/tests/test_bug_views.py
562index 2264af4..787b257 100644
563--- a/lib/lp/bugs/browser/tests/test_bug_views.py
564+++ b/lib/lp/bugs/browser/tests/test_bug_views.py
565@@ -779,6 +779,25 @@ class TestBugMessageAddFormView(TestCaseWithFactory):
566 )
567 self.assertEqual(0, len(view.errors))
568
569+ def test_add_attachment_with_url(self):
570+ bug = self.factory.makeBug()
571+ form = {
572+ "field.comment": " ",
573+ "field.actions.save": "Post Comment",
574+ "field.attachment_url": "https://launchpad.net",
575+ "field.attachment_description": "description",
576+ "field.patch.used": "",
577+ }
578+ login_person(self.factory.makePerson())
579+ view = create_initialized_view(
580+ bug.default_bugtask, "+addcomment", form=form
581+ )
582+ self.assertEqual([], view.errors)
583+ attachments = list(bug.attachments)
584+ self.assertEqual(1, len(attachments))
585+ self.assertEqual("description", attachments[0].title)
586+ self.assertEqual("https://launchpad.net", attachments[0].url)
587+
588
589 class TestBugMarkAsDuplicateView(TestCaseWithFactory):
590 """Tests for marking a bug as a duplicate."""
591diff --git a/lib/lp/bugs/browser/tests/test_bugview.py b/lib/lp/bugs/browser/tests/test_bugview.py
592index 04d2e84..1124544 100644
593--- a/lib/lp/bugs/browser/tests/test_bugview.py
594+++ b/lib/lp/bugs/browser/tests/test_bugview.py
595@@ -35,10 +35,7 @@ class TestBugView(TestCaseWithFactory):
596 removeSecurityProxy(attachment.libraryfile).content = None
597 self.assertEqual(
598 ["regular attachment"],
599- [
600- attachment["attachment"].title
601- for attachment in self.view.regular_attachments
602- ],
603+ [attachment.title for attachment in self.view.regular_attachments],
604 )
605
606 def test_patches_dont_include_invalid_records(self):
607@@ -55,10 +52,7 @@ class TestBugView(TestCaseWithFactory):
608 removeSecurityProxy(patch.libraryfile).content = None
609 self.assertEqual(
610 ["patch"],
611- [
612- attachment["attachment"].title
613- for attachment in self.view.patches
614- ],
615+ [attachment.title for attachment in self.view.patches],
616 )
617
618
619diff --git a/lib/lp/bugs/doc/bug-export.rst b/lib/lp/bugs/doc/bug-export.rst
620index 3f466dd..afe9b09 100644
621--- a/lib/lp/bugs/doc/bug-export.rst
622+++ b/lib/lp/bugs/doc/bug-export.rst
623@@ -142,13 +142,23 @@ the file when we later serialise the bug:
624 >>> bug4 = getUtility(IBugSet).get(4)
625 >>> sampleperson = getUtility(IPersonSet).getByEmail("test@canonical.com")
626 >>> bug4.addAttachment(
627- ... sampleperson,
628- ... io.BytesIO(b"Hello World"),
629- ... "Added attachment",
630- ... "hello.txt",
631+ ... owner=sampleperson,
632+ ... data=io.BytesIO(b"Hello World"),
633+ ... comment="Added attachment",
634+ ... filename="hello.txt",
635+ ... url=None,
636 ... description='"Hello World" attachment',
637 ... )
638 <lp.bugs.model.bugattachment.BugAttachment ...>
639+ >>> bug4.addAttachment(
640+ ... owner=sampleperson,
641+ ... data=None,
642+ ... comment="Added attachment with URL",
643+ ... filename=None,
644+ ... url="https://launchpad.net/",
645+ ... description=None,
646+ ... )
647+ <lp.bugs.model.bugattachment.BugAttachment ...>
648
649 >>> transaction.commit()
650
651@@ -166,13 +176,21 @@ attachment contents encoded using base-64:
652 <text>Added attachment</text>
653 <attachment href="http://bugs.launchpad.test/bugs/4/.../+files/hello.txt">
654 <type>UNSPECIFIED</type>
655- <filename>hello.txt</filename>
656 <title>"Hello World" attachment</title>
657+ <filename>hello.txt</filename>
658 <mimetype>text/plain</mimetype>
659 <contents>SGVsbG8gV29ybGQ=
660 </contents>
661 </attachment>
662 </comment>
663+ <comment>
664+ <sender name="name12">Sample Person</sender>
665+ <date>...</date>
666+ <text>Added attachment with URL</text>
667+ <attachment href="https://launchpad.net/">
668+ <type>UNSPECIFIED</type>
669+ <title>https://launchpad.net/</title>
670+ </attachment>
671 ...
672
673
674diff --git a/lib/lp/bugs/doc/bug.rst b/lib/lp/bugs/doc/bug.rst
675index e28eff2..848028f 100644
676--- a/lib/lp/bugs/doc/bug.rst
677+++ b/lib/lp/bugs/doc/bug.rst
678@@ -742,6 +742,7 @@ Adding an attachment.
679 >>> attachment = attachmentset.create(
680 ... bug=firefox_bug,
681 ... filealias=filealias,
682+ ... url=None,
683 ... title="Some info.",
684 ... message=message,
685 ... )
686diff --git a/lib/lp/bugs/doc/bugattachments.rst b/lib/lp/bugs/doc/bugattachments.rst
687index d2f99c8..58e16f1 100644
688--- a/lib/lp/bugs/doc/bugattachments.rst
689+++ b/lib/lp/bugs/doc/bugattachments.rst
690@@ -53,6 +53,7 @@ ObjectCreatedEvent in order to trigger email notifications:
691 ... owner=foobar,
692 ... data=data,
693 ... filename="foo.bar",
694+ ... url=None,
695 ... description="this fixes the bug",
696 ... comment=message,
697 ... is_patch=False,
698@@ -77,6 +78,7 @@ passed in is often a file-like object, but can be bytes too.
699 ... owner=foobar,
700 ... data=data,
701 ... filename="foo.baz",
702+ ... url=None,
703 ... description="this fixes the bug",
704 ... comment="a string comment",
705 ... is_patch=False,
706@@ -92,6 +94,7 @@ If no description is given, the title is set to the filename.
707 >>> screenshot = bug_four.addAttachment(
708 ... owner=foobar,
709 ... data=data,
710+ ... url=None,
711 ... filename="screenshot.jpg",
712 ... comment="a string comment",
713 ... is_patch=False,
714@@ -110,6 +113,7 @@ The content type is guessed based on the information provided.
715 ... owner=foobar,
716 ... data=data,
717 ... filename="something.debdiff",
718+ ... url=None,
719 ... comment="something debdiffish",
720 ... is_patch=False,
721 ... )
722@@ -503,6 +507,7 @@ It's also possible to delete attachments.
723 ... owner=foobar,
724 ... data=data,
725 ... filename="foo.baz",
726+ ... url=None,
727 ... description="Attachment to be deleted",
728 ... comment="a string comment",
729 ... is_patch=False,
730@@ -552,6 +557,7 @@ property returning True.
731 ... owner=foobar,
732 ... data=BytesIO(filecontent.getvalue()),
733 ... filename="foo.baz",
734+ ... url=None,
735 ... description="A non-patch attachment",
736 ... comment="a string comment",
737 ... is_patch=False,
738@@ -564,6 +570,7 @@ property returning True.
739 ... owner=foobar,
740 ... data=BytesIO(filecontent.getvalue()),
741 ... filename="foo.baz",
742+ ... url=None,
743 ... description="A patch attachment",
744 ... comment="a string comment",
745 ... is_patch=True,
746@@ -601,7 +608,10 @@ LibraryFileAlias.restricted and Bug.private. See also the section
747
748 >>> bug = factory.makeBug()
749 >>> bug.linkAttachment(
750- ... owner=bug.owner, file_alias=file_alias, comment="Some attachment"
751+ ... owner=bug.owner,
752+ ... file_alias=file_alias,
753+ ... url=None,
754+ ... comment="Some attachment",
755 ... )
756 <lp.bugs.model.bugattachment.BugAttachment ...>
757
758@@ -631,6 +641,7 @@ meaningful description.
759 >>> bug.linkAttachment(
760 ... owner=bug.owner,
761 ... file_alias=file_alias,
762+ ... url=None,
763 ... comment="Some attachment",
764 ... is_patch=True,
765 ... description="An attachment of some sort",
766@@ -688,6 +699,7 @@ its Librarian file is set.
767 ... owner=private_bug_owner,
768 ... data=b"secret",
769 ... filename="baz.txt",
770+ ... url=None,
771 ... comment="Some attachment",
772 ... )
773 >>> private_attachment.libraryfile.restricted
774diff --git a/lib/lp/bugs/doc/bugcomment.rst b/lib/lp/bugs/doc/bugcomment.rst
775index 14a8b71..5157e55 100644
776--- a/lib/lp/bugs/doc/bugcomment.rst
777+++ b/lib/lp/bugs/doc/bugcomment.rst
778@@ -77,6 +77,7 @@ in activity_and_comments...
779 ... data=BytesIO(b"whatever"),
780 ... comment=bug_11.initial_message,
781 ... filename="test.txt",
782+ ... url=None,
783 ... is_patch=False,
784 ... content_type="text/plain",
785 ... description="sample data",
786@@ -235,6 +236,7 @@ attachments to a bug to see this in action:
787 ... data=file_,
788 ... description="Ho",
789 ... filename="munchy",
790+ ... url=None,
791 ... comment="Hello there",
792 ... )
793 >>> m6 = bug_three.newMessage(
794@@ -436,6 +438,7 @@ not included in BugComment.patches.
795 ... data=BytesIO(b"whatever"),
796 ... comment=bug.initial_message,
797 ... filename="file1",
798+ ... url=None,
799 ... is_patch=False,
800 ... content_type="text/plain",
801 ... description="sample data 1",
802@@ -445,6 +448,7 @@ not included in BugComment.patches.
803 ... data=BytesIO(b"whatever"),
804 ... comment=bug.initial_message,
805 ... filename="file2",
806+ ... url=None,
807 ... is_patch=False,
808 ... content_type="text/plain",
809 ... description="sample data 2",
810@@ -454,6 +458,7 @@ not included in BugComment.patches.
811 ... data=BytesIO(b"whatever"),
812 ... comment=bug.initial_message,
813 ... filename="patch1",
814+ ... url=None,
815 ... is_patch=True,
816 ... content_type="text/plain",
817 ... description="patch 1",
818@@ -463,6 +468,7 @@ not included in BugComment.patches.
819 ... data=BytesIO(b"whatever"),
820 ... comment=bug.initial_message,
821 ... filename="patch2",
822+ ... url=None,
823 ... is_patch=True,
824 ... content_type="text/plain",
825 ... description="patch 2",
826diff --git a/lib/lp/bugs/interfaces/bug.py b/lib/lp/bugs/interfaces/bug.py
827index 9697660..e02f2ff 100644
828--- a/lib/lp/bugs/interfaces/bug.py
829+++ b/lib/lp/bugs/interfaces/bug.py
830@@ -43,6 +43,7 @@ from lazr.restful.interface import copy_field
831 from zope.component import getUtility
832 from zope.interface import Attribute, Interface
833 from zope.schema import (
834+ URI,
835 Bool,
836 Bytes,
837 Choice,
838@@ -877,11 +878,12 @@ class IBugAppend(Interface):
839
840 @call_with(owner=REQUEST_USER, from_api=True)
841 @operation_parameters(
842- data=Bytes(constraint=attachment_size_constraint),
843+ data=Bytes(constraint=attachment_size_constraint, required=False),
844 comment=Text(),
845- filename=TextLine(),
846+ filename=TextLine(required=False),
847+ url=URI(required=False),
848 is_patch=Bool(),
849- content_type=TextLine(),
850+ content_type=TextLine(required=False),
851 description=Text(),
852 )
853 @export_factory_operation(IBugAttachment, [])
854@@ -891,6 +893,7 @@ class IBugAppend(Interface):
855 data,
856 comment,
857 filename,
858+ url,
859 is_patch=False,
860 content_type=None,
861 description=None,
862@@ -903,6 +906,7 @@ class IBugAppend(Interface):
863 :description: A brief description of the attachment.
864 :comment: An IMessage or string.
865 :filename: A string.
866+ :url: External URL of the attachment
867 :is_patch: A boolean.
868 """
869
870@@ -956,12 +960,13 @@ class IBugAppend(Interface):
871 """
872
873 def linkAttachment(
874- owner, file_alias, comment, is_patch=False, description=None
875+ owner, file_alias, url, comment, is_patch=False, description=None
876 ):
877 """Link an `ILibraryFileAlias` to this bug.
878
879 :owner: An IPerson.
880 :file_alias: The `ILibraryFileAlias` to link to this bug.
881+ :url: External URL of the attachment.
882 :description: A brief description of the attachment.
883 :comment: An IMessage or string.
884 :is_patch: A boolean.
885diff --git a/lib/lp/bugs/interfaces/bugattachment.py b/lib/lp/bugs/interfaces/bugattachment.py
886index 0ded472..4edb5de 100644
887--- a/lib/lp/bugs/interfaces/bugattachment.py
888+++ b/lib/lp/bugs/interfaces/bugattachment.py
889@@ -22,7 +22,7 @@ from lazr.restful.declarations import (
890 )
891 from lazr.restful.fields import Reference
892 from zope.interface import Interface
893-from zope.schema import Bool, Bytes, Choice, Int, TextLine
894+from zope.schema import URI, Bool, Bytes, Choice, Int, TextLine
895
896 from lp import _
897 from lp.bugs.interfaces.hasbug import IHasBug
898@@ -90,10 +90,13 @@ class IBugAttachmentView(IHasBug):
899 )
900 libraryfile = exported(
901 Bytes(
902- title=_("The attachment content."), required=True, readonly=True
903+ title=_("The attachment content."), required=False, readonly=True
904 ),
905 exported_as="data",
906 )
907+ url = exported(
908+ URI(title=_("Attachment URL"), required=False, readonly=True)
909+ )
910 _message_id = Int(title=_("Message ID"))
911 message = exported(
912 Reference(
913@@ -109,6 +112,12 @@ class IBugAttachmentView(IHasBug):
914 description=_("Is this attachment a patch?"),
915 readonly=True,
916 )
917+ displayed_url = URI(
918+ title=_(
919+ "Download URL of the files or the external URL of the attachment"
920+ ),
921+ readonly=True,
922+ )
923
924 def getFileByName(filename):
925 """Return the `ILibraryFileAlias` for the given file name.
926@@ -176,6 +185,7 @@ class IBugAttachmentSet(Interface):
927 def create(
928 bug,
929 filealias,
930+ url,
931 title,
932 message,
933 type=IBugAttachment["type"].default,
934@@ -184,7 +194,9 @@ class IBugAttachmentSet(Interface):
935 """Create a new attachment and return it.
936
937 :param bug: The `IBug` to which the new attachment belongs.
938- :param filealias: The `IFilealias` containing the data.
939+ :param filealias: The `ILibraryFileAlias` containing
940+ the data (optional).
941+ :param url: External URL of the attachment (optional).
942 :param message: The `IMessage` to which this attachment belongs.
943 :param type: The type of attachment. See `BugAttachmentType`.
944 :param send_notifications: If True, a notification is sent to
945diff --git a/lib/lp/bugs/interfaces/bugmessage.py b/lib/lp/bugs/interfaces/bugmessage.py
946index 7768b07..22c8d39 100644
947--- a/lib/lp/bugs/interfaces/bugmessage.py
948+++ b/lib/lp/bugs/interfaces/bugmessage.py
949@@ -11,7 +11,7 @@ __all__ = [
950 ]
951
952 from zope.interface import Attribute, Interface
953-from zope.schema import Bool, Bytes, Int, Object, Text, TextLine
954+from zope.schema import URI, Bool, Bytes, Int, Object, Text, TextLine
955
956 from lp.app.validators.attachment import attachment_size_constraint
957 from lp.bugs.interfaces.bug import IBug
958@@ -103,6 +103,7 @@ class IBugMessageAddForm(Interface):
959 required=False,
960 constraint=attachment_size_constraint,
961 )
962+ attachment_url = URI(title="URL", required=False)
963 patch = Bool(
964 title="This attachment contains a solution (patch) for this bug",
965 required=False,
966diff --git a/lib/lp/bugs/mail/handler.py b/lib/lp/bugs/mail/handler.py
967index 975af40..775b570 100644
968--- a/lib/lp/bugs/mail/handler.py
969+++ b/lib/lp/bugs/mail/handler.py
970@@ -410,6 +410,7 @@ class MaloneHandler:
971 getUtility(IBugAttachmentSet).create(
972 bug=bug,
973 filealias=blob,
974+ url=None,
975 attach_type=attach_type,
976 title=blob.filename,
977 message=message,
978diff --git a/lib/lp/bugs/model/bug.py b/lib/lp/bugs/model/bug.py
979index 5b1465c..88f793d 100644
980--- a/lib/lp/bugs/model/bug.py
981+++ b/lib/lp/bugs/model/bug.py
982@@ -1608,6 +1608,7 @@ class Bug(SQLBase, InformationTypeMixin):
983 data,
984 comment,
985 filename,
986+ url,
987 is_patch=False,
988 content_type=None,
989 description=None,
990@@ -1619,35 +1620,45 @@ class Bug(SQLBase, InformationTypeMixin):
991 # wrongly encoded.
992 if from_api:
993 data = get_raw_form_value_from_current_request(data, "data")
994- if isinstance(data, bytes):
995- filecontent = data
996- else:
997- filecontent = data.read()
998
999- if is_patch:
1000- content_type = "text/plain"
1001- else:
1002- if content_type is None:
1003- content_type, encoding = guess_content_type(
1004- name=filename, body=filecontent
1005- )
1006+ if data:
1007+ if isinstance(data, bytes):
1008+ filecontent = data
1009+ else:
1010+ filecontent = data.read()
1011
1012- filealias = getUtility(ILibraryFileAliasSet).create(
1013- name=filename,
1014- size=len(filecontent),
1015- file=BytesIO(filecontent),
1016- contentType=content_type,
1017- restricted=self.private,
1018- )
1019+ if is_patch:
1020+ content_type = "text/plain"
1021+ else:
1022+ if content_type is None:
1023+ content_type, encoding = guess_content_type(
1024+ name=filename, body=filecontent
1025+ )
1026+
1027+ file_alias = getUtility(ILibraryFileAliasSet).create(
1028+ name=filename,
1029+ size=len(filecontent),
1030+ file=BytesIO(filecontent),
1031+ contentType=content_type,
1032+ restricted=self.private,
1033+ )
1034+ else:
1035+ file_alias = None
1036
1037 return self.linkAttachment(
1038- owner, filealias, comment, is_patch, description
1039+ owner=owner,
1040+ file_alias=file_alias,
1041+ url=url,
1042+ comment=comment,
1043+ is_patch=is_patch,
1044+ description=description,
1045 )
1046
1047 def linkAttachment(
1048 self,
1049 owner,
1050 file_alias,
1051+ url,
1052 comment,
1053 is_patch=False,
1054 description=None,
1055@@ -1670,11 +1681,6 @@ class Bug(SQLBase, InformationTypeMixin):
1056 else:
1057 attach_type = BugAttachmentType.UNSPECIFIED
1058
1059- if description:
1060- title = description
1061- else:
1062- title = file_alias.filename
1063-
1064 if IMessage.providedBy(comment):
1065 message = comment
1066 else:
1067@@ -1685,8 +1691,9 @@ class Bug(SQLBase, InformationTypeMixin):
1068 return getUtility(IBugAttachmentSet).create(
1069 bug=self,
1070 filealias=file_alias,
1071+ url=url,
1072 attach_type=attach_type,
1073- title=title,
1074+ title=description,
1075 message=message,
1076 send_notifications=send_notifications,
1077 )
1078@@ -2206,9 +2213,10 @@ class Bug(SQLBase, InformationTypeMixin):
1079 # XXX: This should be a bulk update. RBC 20100827
1080 # bug=https://bugs.launchpad.net/storm/+bug/625071
1081 for attachment in self.attachments_unpopulated:
1082- attachment.libraryfile.restricted = (
1083- information_type in PRIVATE_INFORMATION_TYPES
1084- )
1085+ if attachment.libraryfile:
1086+ attachment.libraryfile.restricted = (
1087+ information_type in PRIVATE_INFORMATION_TYPES
1088+ )
1089
1090 self.information_type = information_type
1091 self._reconcileAccess()
1092@@ -2558,16 +2566,34 @@ class Bug(SQLBase, InformationTypeMixin):
1093
1094 def _attachments_query(self):
1095 """Helper for the attachments* properties."""
1096- # bug attachments with no LibraryFileContent have been deleted - the
1097- # garbo_daily run will remove the LibraryFileAlias asynchronously.
1098- # See bug 542274 for more details.
1099+ # get bug attachments that have either a URL,
1100+ # or associated LibraryFileContent
1101+ # bug attachments with LibraryFileAlias and no LibraryFileContent have
1102+ # been deleted - the garbo_daily run will remove the LibraryFileAlias
1103+ # asynchronously. See bug 542274 for more details.
1104 store = Store.of(self)
1105- return store.find(
1106- (BugAttachment, LibraryFileAlias, LibraryFileContent),
1107- BugAttachment.bug == self,
1108- BugAttachment.libraryfile == LibraryFileAlias.id,
1109- LibraryFileContent.id == LibraryFileAlias.contentID,
1110- ).order_by(BugAttachment.id)
1111+ return (
1112+ store.using(
1113+ BugAttachment,
1114+ LeftJoin(
1115+ LibraryFileAlias,
1116+ BugAttachment.libraryfile == LibraryFileAlias.id,
1117+ ),
1118+ LeftJoin(
1119+ LibraryFileContent,
1120+ LibraryFileContent.id == LibraryFileAlias.contentID,
1121+ ),
1122+ )
1123+ .find(
1124+ (BugAttachment, LibraryFileAlias, LibraryFileContent),
1125+ BugAttachment.bug == self,
1126+ Or(
1127+ BugAttachment.url != None,
1128+ LibraryFileAlias.contentID != None,
1129+ ),
1130+ )
1131+ .order_by(BugAttachment.id)
1132+ )
1133
1134 @property
1135 def attachments(self):
1136diff --git a/lib/lp/bugs/model/bugattachment.py b/lib/lp/bugs/model/bugattachment.py
1137index 180c353..064482a 100644
1138--- a/lib/lp/bugs/model/bugattachment.py
1139+++ b/lib/lp/bugs/model/bugattachment.py
1140@@ -17,6 +17,7 @@ from lp.bugs.interfaces.bugattachment import (
1141 from lp.services.database.enumcol import DBEnum
1142 from lp.services.database.interfaces import IStore
1143 from lp.services.database.stormbase import StormBase
1144+from lp.services.librarian.browser import ProxiedLibraryFileAlias
1145 from lp.services.propertycache import cachedproperty
1146
1147
1148@@ -35,9 +36,10 @@ class BugAttachment(StormBase):
1149 allow_none=False,
1150 default=IBugAttachment["type"].default,
1151 )
1152- title = Unicode(allow_none=False)
1153- libraryfile_id = Int(name="libraryfile", allow_none=False)
1154+ _title = Unicode(name="title", allow_none=True)
1155+ libraryfile_id = Int(name="libraryfile", allow_none=True)
1156 libraryfile = Reference(libraryfile_id, "LibraryFileAlias.id")
1157+ url = Unicode(allow_none=True)
1158 _message_id = Int(name="message", allow_none=False)
1159 _message = Reference(_message_id, "Message.id")
1160
1161@@ -46,6 +48,7 @@ class BugAttachment(StormBase):
1162 bug,
1163 title,
1164 libraryfile,
1165+ url,
1166 message,
1167 type=IBugAttachment["type"].default,
1168 ):
1169@@ -53,9 +56,22 @@ class BugAttachment(StormBase):
1170 self.bug = bug
1171 self.title = title
1172 self.libraryfile = libraryfile
1173+ self.url = url
1174 self._message = message
1175 self.type = type
1176
1177+ @property
1178+ def title(self) -> str:
1179+ if self._title:
1180+ return self._title
1181+ if self.libraryfile:
1182+ return self.libraryfile.filename
1183+ return self.url
1184+
1185+ @title.setter
1186+ def title(self, title) -> None:
1187+ self._title = title
1188+
1189 @cachedproperty
1190 def message(self):
1191 """This is a cachedproperty to allow message to be an IIndexedMessage.
1192@@ -81,15 +97,23 @@ class BugAttachment(StormBase):
1193 # Delete the reference to the LibraryFileContent record right now,
1194 # in order to avoid problems with not deleted files as described
1195 # in bug 387188.
1196- self.libraryfile.content = None
1197+ if self.libraryfile:
1198+ self.libraryfile.content = None
1199 Store.of(self).remove(self)
1200
1201 def getFileByName(self, filename):
1202 """See IBugAttachment."""
1203- if filename == self.libraryfile.filename:
1204+ if self.libraryfile and filename == self.libraryfile.filename:
1205 return self.libraryfile
1206 raise NotFoundError(filename)
1207
1208+ @property
1209+ def displayed_url(self):
1210+ return (
1211+ self.url
1212+ or ProxiedLibraryFileAlias(self.libraryfile, self).http_url
1213+ )
1214+
1215
1216 @implementer(IBugAttachmentSet)
1217 class BugAttachmentSet:
1218@@ -110,18 +134,26 @@ class BugAttachmentSet:
1219 self,
1220 bug,
1221 filealias,
1222+ url,
1223 title,
1224 message,
1225 attach_type=None,
1226 send_notifications=False,
1227 ):
1228 """See `IBugAttachmentSet`."""
1229+ if not filealias and not url:
1230+ raise ValueError("Either filealias or url must be provided")
1231+
1232+ if filealias and url:
1233+ raise ValueError("Only one of filealias or url may be provided")
1234+
1235 if attach_type is None:
1236 # XXX kiko 2005-08-03 bug=1659: this should use DEFAULT.
1237 attach_type = IBugAttachment["type"].default
1238 attachment = BugAttachment(
1239 bug=bug,
1240 libraryfile=filealias,
1241+ url=url,
1242 type=attach_type,
1243 title=title,
1244 message=message,
1245diff --git a/lib/lp/bugs/model/tests/test_bugtasksearch.py b/lib/lp/bugs/model/tests/test_bugtasksearch.py
1246index db52d71..05793e0 100644
1247--- a/lib/lp/bugs/model/tests/test_bugtasksearch.py
1248+++ b/lib/lp/bugs/model/tests/test_bugtasksearch.py
1249@@ -268,6 +268,7 @@ class OnceTests:
1250 data=b"filedata",
1251 comment="a comment",
1252 filename="file1.txt",
1253+ url=None,
1254 is_patch=False,
1255 )
1256 self.bugtasks[1].bug.addAttachment(
1257@@ -275,6 +276,7 @@ class OnceTests:
1258 data=b"filedata",
1259 comment="a comment",
1260 filename="file1.txt",
1261+ url=None,
1262 is_patch=True,
1263 )
1264 # We can search for bugs with non-patch attachments...
1265diff --git a/lib/lp/bugs/scripts/bugexport.py b/lib/lp/bugs/scripts/bugexport.py
1266index 3d3327a..c6c0c43 100644
1267--- a/lib/lp/bugs/scripts/bugexport.py
1268+++ b/lib/lp/bugs/scripts/bugexport.py
1269@@ -16,7 +16,6 @@ from lp.app.interfaces.launchpad import ILaunchpadCelebrities
1270 from lp.bugs.browser.bugtask import get_comments_for_bugtask
1271 from lp.bugs.interfaces.bugtask import IBugTaskSet
1272 from lp.bugs.interfaces.bugtasksearch import BugTaskSearchParams
1273-from lp.services.librarian.browser import ProxiedLibraryFileAlias
1274
1275 BUGS_XMLNS = "https://launchpad.net/xmlns/2006/bugs"
1276
1277@@ -85,27 +84,30 @@ def serialise_bugtask(bugtask):
1278 attachment_node = ET.SubElement(
1279 comment_node,
1280 "attachment",
1281- href=ProxiedLibraryFileAlias(
1282- attachment.libraryfile, attachment
1283- ).http_url,
1284+ href=attachment.displayed_url,
1285 )
1286 attachment_node.text = attachment_node.tail = "\n"
1287 addnode(attachment_node, "type", attachment.type.name)
1288- addnode(
1289- attachment_node, "filename", attachment.libraryfile.filename
1290- )
1291 addnode(attachment_node, "title", attachment.title)
1292- addnode(
1293- attachment_node, "mimetype", attachment.libraryfile.mimetype
1294- )
1295- # Attach the attachment file contents, base 64 encoded.
1296- addnode(
1297- attachment_node,
1298- "contents",
1299- base64.encodebytes(attachment.libraryfile.read()).decode(
1300- "ASCII"
1301- ),
1302- )
1303+ if attachment.libraryfile:
1304+ addnode(
1305+ attachment_node,
1306+ "filename",
1307+ attachment.libraryfile.filename,
1308+ )
1309+ addnode(
1310+ attachment_node,
1311+ "mimetype",
1312+ attachment.libraryfile.mimetype,
1313+ )
1314+ # Attach the attachment file contents, base 64 encoded.
1315+ addnode(
1316+ attachment_node,
1317+ "contents",
1318+ base64.encodebytes(attachment.libraryfile.read()).decode(
1319+ "ASCII"
1320+ ),
1321+ )
1322
1323 return bug_node
1324
1325diff --git a/lib/lp/bugs/scripts/bugimport.py b/lib/lp/bugs/scripts/bugimport.py
1326index 323c9b1..38306fb 100644
1327--- a/lib/lp/bugs/scripts/bugimport.py
1328+++ b/lib/lp/bugs/scripts/bugimport.py
1329@@ -445,6 +445,7 @@ class BugImporter:
1330 getUtility(IBugAttachmentSet).create(
1331 bug=bug,
1332 filealias=filealias,
1333+ url=None,
1334 attach_type=attach_type,
1335 title=title,
1336 message=message,
1337diff --git a/lib/lp/bugs/scripts/tests/test_bugnotification.py b/lib/lp/bugs/scripts/tests/test_bugnotification.py
1338index f9aa65d..80fee9c 100644
1339--- a/lib/lp/bugs/scripts/tests/test_bugnotification.py
1340+++ b/lib/lp/bugs/scripts/tests/test_bugnotification.py
1341@@ -1026,7 +1026,7 @@ class TestEmailNotificationsAttachments(
1342 # another five minutes. Therefore, we send out separate
1343 # change notifications.
1344 return self.bug.addAttachment(
1345- self.person, b"content", "a comment", "stuff.txt"
1346+ self.person, b"content", "a comment", "stuff.txt", url=None
1347 )
1348
1349 old = cachedproperty("old")(_attachment)
1350diff --git a/lib/lp/bugs/stories/bugattachments/xx-attachments-to-bug-report.rst b/lib/lp/bugs/stories/bugattachments/xx-attachments-to-bug-report.rst
1351index 090ca83..9df6720 100644
1352--- a/lib/lp/bugs/stories/bugattachments/xx-attachments-to-bug-report.rst
1353+++ b/lib/lp/bugs/stories/bugattachments/xx-attachments-to-bug-report.rst
1354@@ -35,6 +35,7 @@ If we add an attachment to the bug report...
1355 ... data=BytesIO(b"whatever"),
1356 ... comment=bug_11.initial_message,
1357 ... filename="test.txt",
1358+ ... url=None,
1359 ... is_patch=False,
1360 ... content_type="text/plain",
1361 ... description="sample data",
1362@@ -74,6 +75,7 @@ Patch attachments are shown before non-patch attachments
1363 ... data=BytesIO(b"patchy patch patch"),
1364 ... comment=bug_11.initial_message,
1365 ... filename="patch.txt",
1366+ ... url=None,
1367 ... is_patch=True,
1368 ... content_type="text/plain",
1369 ... description="a patch",
1370diff --git a/lib/lp/bugs/stories/bugs/xx-bug-text-pages.rst b/lib/lp/bugs/stories/bugs/xx-bug-text-pages.rst
1371index 93e9cc6..bfa0b6e 100644
1372--- a/lib/lp/bugs/stories/bugs/xx-bug-text-pages.rst
1373+++ b/lib/lp/bugs/stories/bugs/xx-bug-text-pages.rst
1374@@ -23,6 +23,7 @@ We'll start by adding some attachments to the bug:
1375 ... content,
1376 ... "comment for file a",
1377 ... "file_a.txt",
1378+ ... url=None,
1379 ... content_type="text/html",
1380 ... )
1381 >>> content = BytesIO(b"do we need to")
1382@@ -31,6 +32,7 @@ We'll start by adding some attachments to the bug:
1383 ... content,
1384 ... "comment for file with space",
1385 ... "file with space.txt",
1386+ ... url=None,
1387 ... content_type='text/plain;\n name="file with space.txt"',
1388 ... )
1389 >>> content = BytesIO(b"Yes we can!")
1390@@ -39,6 +41,7 @@ We'll start by adding some attachments to the bug:
1391 ... content,
1392 ... "comment for patch",
1393 ... "bug-patch.diff",
1394+ ... url=None,
1395 ... is_patch=True,
1396 ... content_type="text/plain",
1397 ... description="a patch",
1398diff --git a/lib/lp/bugs/stories/bugtask-searches/xx-listing-basics.rst b/lib/lp/bugs/stories/bugtask-searches/xx-listing-basics.rst
1399index 12f5e92..bed6dba 100644
1400--- a/lib/lp/bugs/stories/bugtask-searches/xx-listing-basics.rst
1401+++ b/lib/lp/bugs/stories/bugtask-searches/xx-listing-basics.rst
1402@@ -311,6 +311,7 @@ Patches also appear as badges in bug listings.
1403 ... owner=foobar,
1404 ... data=BytesIO(b"file data"),
1405 ... filename="foo.bar",
1406+ ... url=None,
1407 ... description="this fixes the bug",
1408 ... comment=message,
1409 ... is_patch=True,
1410diff --git a/lib/lp/bugs/stories/patches-view/patches-view.rst b/lib/lp/bugs/stories/patches-view/patches-view.rst
1411index 8a5d5a7..786a75d 100644
1412--- a/lib/lp/bugs/stories/patches-view/patches-view.rst
1413+++ b/lib/lp/bugs/stories/patches-view/patches-view.rst
1414@@ -101,7 +101,7 @@ patches view.
1415 Bug Importance Status Patch Age
1416 Bug #...: bug_a title Undecided New ...second...
1417 From: Patchy Person
1418- Link: patch_a.diff description of patch a
1419+ Link: description of patch a
1420
1421 The page title and other wording in the page reflects the contents.
1422
1423@@ -205,13 +205,13 @@ attachments, and various statuses...
1424 Bug Importance Status Patch Age
1425 Bug #...: bug_c title Wishlist Fix Committed ...second...
1426 From: Patchy Person
1427- Link: patch_f.diff description of patch f
1428+ Link: description of patch f
1429 Bug #...: bug_b title Critical Confirmed ...second...
1430 From: Patchy Person
1431- Link: patch_c.diff description of patch c
1432+ Link: description of patch c
1433 Bug #...: bug_a title Undecided New ...second...
1434 From: Patchy Person
1435- Link: patch_a.diff description of patch a
1436+ Link: description of patch a
1437
1438 The default sort order is by patch age. We can sort patches by
1439 importance and status.
1440@@ -224,13 +224,13 @@ importance and status.
1441 Bug Importance Status Patch Age
1442 Bug #...: bug_b title Critical Confirmed ...second...
1443 From: Patchy Person
1444- Link: patch_c.diff description of patch c
1445+ Link: description of patch c
1446 Bug #...: bug_c title Wishlist Fix Committed ...second...
1447 From: Patchy Person
1448- Link: patch_f.diff description of patch f
1449+ Link: description of patch f
1450 Bug #...: bug_a title Undecided New ...second...
1451 From: Patchy Person
1452- Link: patch_a.diff description of patch a
1453+ Link: description of patch a
1454
1455 >>> anon_browser.getControl(name="orderby").value = ["status"]
1456 >>> anon_browser.getControl("sort").click()
1457@@ -240,13 +240,13 @@ importance and status.
1458 Bug Importance Status Patch Age
1459 Bug #...: bug_a title Undecided New ...second...
1460 From: Patchy Person
1461- Link: patch_a.diff description of patch a
1462+ Link: description of patch a
1463 Bug #...: bug_b title Critical Confirmed ...second...
1464 From: Patchy Person
1465- Link: patch_c.diff description of patch c
1466+ Link: description of patch c
1467 Bug #...: bug_c title Wishlist Fix Committed ...second...
1468 From: Patchy Person
1469- Link: patch_f.diff description of patch f
1470+ Link: description of patch f
1471
1472 But we can't sort by things that aren't validated by the view.
1473
1474@@ -327,12 +327,10 @@ Bugs in a product series show up in the patches view for that series.
1475 Bug Importance Status Patch Age
1476 Bug #...: bug_c title Wishlist Fix Committed ...second...
1477 From: Patchy Person
1478- Link: patch_f.diff
1479- description of patch f
1480+ Link: description of patch f
1481 Bug #...: bug_a title Undecided New ...second...
1482 From: Patchy Person
1483- Link: patch_a.diff
1484- description of patch a
1485+ Link: description of patch a
1486
1487 The page title and other wording in the page reflects the contents.
1488
1489diff --git a/lib/lp/bugs/stories/webservice/xx-bug.rst b/lib/lp/bugs/stories/webservice/xx-bug.rst
1490index 80c2f24..8266254 100644
1491--- a/lib/lp/bugs/stories/webservice/xx-bug.rst
1492+++ b/lib/lp/bugs/stories/webservice/xx-bug.rst
1493@@ -1531,6 +1531,7 @@ An attachment can be added to the bug:
1494 ... "addAttachment",
1495 ... data=io.BytesIO(b"12345"),
1496 ... filename="numbers.txt",
1497+ ... url=None,
1498 ... content_type="foo/bar",
1499 ... comment="The numbers you asked for.",
1500 ... )
1501@@ -1558,6 +1559,7 @@ Now, bug 1 has one attachment:
1502 self_link: 'http://.../bugs/1/+attachment/...'
1503 title: 'numbers.txt'
1504 type: 'Unspecified'
1505+ url: None
1506 web_link: 'http://bugs.../bugs/1/+attachment/...'
1507 ---
1508
1509@@ -1572,6 +1574,7 @@ The attachment can be fetched directly:
1510 self_link: 'http://.../bugs/1/+attachment/...'
1511 title: 'numbers.txt'
1512 type: 'Unspecified'
1513+ url: None
1514 web_link: 'http://bugs.../bugs/1/+attachment/...'
1515
1516 Fetching the data actually yields a redirect to the Librarian, which
1517diff --git a/lib/lp/bugs/templates/bug-attachment-macros.pt b/lib/lp/bugs/templates/bug-attachment-macros.pt
1518index a06b835..d91d0c3 100644
1519--- a/lib/lp/bugs/templates/bug-attachment-macros.pt
1520+++ b/lib/lp/bugs/templates/bug-attachment-macros.pt
1521@@ -15,6 +15,12 @@
1522 <metal:widget metal:use-macro="context/@@launchpad_form/widget_row" />
1523 </tal:filecontent>
1524
1525+ <tal:url
1526+ tal:define="widget nocall:view/widgets/attachment_url|nothing"
1527+ tal:condition="widget">
1528+ <metal:widget metal:use-macro="context/@@launchpad_form/widget_row" />
1529+ </tal:url>
1530+
1531 <tal:patch
1532 tal:define="widget nocall:view/widgets/patch|nothing"
1533 tal:condition="widget">
1534diff --git a/lib/lp/bugs/templates/bug-portlet-attachments.pt b/lib/lp/bugs/templates/bug-portlet-attachments.pt
1535index 5a79a9f..399ceff 100644
1536--- a/lib/lp/bugs/templates/bug-portlet-attachments.pt
1537+++ b/lib/lp/bugs/templates/bug-portlet-attachments.pt
1538@@ -8,14 +8,10 @@
1539 <ul>
1540 <li class="download-attachment"
1541 tal:repeat="attachment view/patches">
1542- <a tal:attributes="href attachment/file/http_url"
1543- tal:content="attachment/attachment/title"
1544- class="sprite haspatch-icon">
1545- Attachment Title
1546- </a>
1547- <small>
1548- (<a tal:attributes="href attachment/attachment/fmt:url">edit</a>)
1549- </small>
1550+ <a tal:replace="structure attachment/fmt:attachment_link" />
1551+ <a class="sprite edit action-icon"
1552+ title="Change patch details"
1553+ tal:attributes="href attachment/fmt:url">Edit</a>
1554 </li>
1555 </ul>
1556 <ul>
1557@@ -31,14 +27,10 @@
1558 <ul>
1559 <li class="download-attachment"
1560 tal:repeat="attachment view/regular_attachments">
1561- <a tal:attributes="href attachment/file/http_url"
1562- tal:content="attachment/attachment/title"
1563- class="sprite download-icon">
1564- Attachment Title
1565- </a>
1566- <small>
1567- (<a tal:attributes="href attachment/attachment/fmt:url">edit</a>)
1568- </small>
1569+ <a tal:replace="structure attachment/fmt:attachment_link" />
1570+ <a class="sprite edit action-icon"
1571+ title="Change attachment details"
1572+ tal:attributes="href attachment/fmt:url">Edit</a>
1573 </li>
1574 </ul>
1575 <ul>
1576diff --git a/lib/lp/bugs/templates/bugcomment-box.pt b/lib/lp/bugs/templates/bugcomment-box.pt
1577index b25c482..58a9458 100644
1578--- a/lib/lp/bugs/templates/bugcomment-box.pt
1579+++ b/lib/lp/bugs/templates/bugcomment-box.pt
1580@@ -113,24 +113,25 @@
1581 <ul tal:condition="comment/bugattachments" style="margin-bottom: 1em">
1582 <li tal:repeat="attachment comment/bugattachments"
1583 class="download-attachment">
1584- <a tal:attributes="href python: view.proxiedUrlOfLibraryFileAlias(attachment)"
1585- tal:content="attachment/title"
1586- class="sprite download-icon">foo.txt</a>
1587+ <a tal:replace="structure attachment/fmt:attachment_link" />
1588 <a tal:attributes="href attachment/fmt:url" class="sprite edit action-icon">Edit</a>
1589+ <tal:block condition="attachment/libraryfile">
1590 (<span
1591 tal:replace="attachment/libraryfile/content/filesize/fmt:bytes" />,
1592 <span tal:replace="attachment/libraryfile/mimetype" />)
1593+ </tal:block>
1594 </li>
1595 </ul>
1596
1597 <ul tal:condition="comment/patches" style="margin-bottom: 1em">
1598 <li tal:repeat="attachment comment/patches" class="download-attachment">
1599- <a tal:attributes="href python: view.proxiedUrlOfLibraryFileAlias(attachment)"
1600- tal:content="attachment/title" class="sprite haspatch-icon">foo.txt</a>
1601+ <a tal:replace="structure attachment/fmt:attachment_link" />
1602 <a tal:attributes="href attachment/fmt:url" class="sprite edit action-icon">Edit</a>
1603+ <tal:block condition="attachment/libraryfile">
1604 (<span
1605 tal:replace="attachment/libraryfile/content/filesize/fmt:bytes" />,
1606 <span tal:replace="attachment/libraryfile/mimetype" />)
1607+ </tal:block>
1608 </li>
1609 </ul>
1610
1611diff --git a/lib/lp/bugs/templates/bugtarget-patches.pt b/lib/lp/bugs/templates/bugtarget-patches.pt
1612index fca68c7..b4a0f8d 100644
1613--- a/lib/lp/bugs/templates/bugtarget-patches.pt
1614+++ b/lib/lp/bugs/templates/bugtarget-patches.pt
1615@@ -74,7 +74,7 @@
1616 tal:define="patch patch_task/bug/latest_patch;
1617 age python:view.patchAge(patch)"
1618 tal:attributes="id string:patch-cell-${repeat/patch_task/index}">
1619- <a tal:attributes="href python:view.proxiedUrlForLibraryFile(patch)"
1620+ <a tal:attributes="href patch/displayed_url; rel python:patch.url and 'nofollow' or None"
1621 tal:content="age/fmt:approximateduration"></a>
1622 <div class="popupTitle"
1623 tal:attributes="id string:patch-popup-${repeat/patch_task/index};">
1624@@ -82,9 +82,8 @@
1625 <strong>From:</strong>
1626 <a tal:replace="structure submitter/fmt:link"></a><br/>
1627 <strong>Link:</strong>
1628- <a tal:attributes="href python:view.proxiedUrlForLibraryFile(patch)"
1629- tal:content="patch/libraryfile/filename"></a></p>
1630- <p tal:content="string:${patch/title}"></p>
1631+ <a tal:replace="structure patch/fmt:attachment_link" />
1632+ </p>
1633 </div>
1634 <script type="text/javascript" tal:content="string:
1635 LPJS.use('base', 'node', 'event', function(Y) {
1636diff --git a/lib/lp/bugs/tests/bugs-emailinterface.rst b/lib/lp/bugs/tests/bugs-emailinterface.rst
1637index 8e765d1..61b1ef7 100644
1638--- a/lib/lp/bugs/tests/bugs-emailinterface.rst
1639+++ b/lib/lp/bugs/tests/bugs-emailinterface.rst
1640@@ -657,7 +657,7 @@ otherwise permission to complete the operation will be denied.)
1641 We will also add an attachment to the bug.
1642
1643 >>> bug_attachment = bug_four.addAttachment(
1644- ... bug_four.owner, b"Attachment", "No comment", "test.txt"
1645+ ... bug_four.owner, b"Attachment", "No comment", "test.txt", url=None
1646 ... )
1647
1648 >>> submit_commands(bug_four, "private yes")
1649diff --git a/lib/lp/scripts/tests/test_garbo.py b/lib/lp/scripts/tests/test_garbo.py
1650index b33159e..dbdedf5 100644
1651--- a/lib/lp/scripts/tests/test_garbo.py
1652+++ b/lib/lp/scripts/tests/test_garbo.py
1653@@ -1209,13 +1209,15 @@ class TestGarbo(FakeAdapterMixin, TestCaseWithFactory):
1654 switch_dbuser("testadmin")
1655 bug = self.factory.makeBug()
1656 attachment = self.factory.makeBugAttachment(bug=bug)
1657+ # Attachments with URLs are never deleted.
1658+ self.factory.makeBugAttachment(bug=bug, url="https://launchpad.net")
1659 transaction.commit()
1660
1661 # Bug attachments that have a LibraryFileContent record are
1662 # not deleted.
1663 self.assertIsNot(attachment.libraryfile.content, None)
1664 self.runDaily()
1665- self.assertEqual(bug.attachments.count(), 1)
1666+ self.assertEqual(bug.attachments.count(), 2)
1667
1668 # But once we delete the LfC record, the attachment is deleted
1669 # in the next daily garbo run.
1670@@ -1224,7 +1226,7 @@ class TestGarbo(FakeAdapterMixin, TestCaseWithFactory):
1671 transaction.commit()
1672 self.runDaily()
1673 switch_dbuser("testadmin")
1674- self.assertEqual(bug.attachments.count(), 0)
1675+ self.assertEqual(bug.attachments.count(), 1)
1676
1677 def test_TimeLimitedTokenPruner(self):
1678 # Ensure there are no tokens
1679diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
1680index e25375f..226dc96 100644
1681--- a/lib/lp/testing/factory.py
1682+++ b/lib/lp/testing/factory.py
1683@@ -2565,6 +2565,7 @@ class LaunchpadObjectFactory(ObjectFactory):
1684 filename=None,
1685 content_type=None,
1686 description=None,
1687+ url=None,
1688 is_patch=_DEFAULT,
1689 ):
1690 """Create and return a new bug attachment.
1691@@ -2581,23 +2582,31 @@ class LaunchpadObjectFactory(ObjectFactory):
1692 string will be used.
1693 :param content_type: The MIME-type of this file.
1694 :param description: The description of the attachment.
1695+ :param url: External URL of the attachment (a string or None)
1696 :param is_patch: If true, this attachment is a patch.
1697 :return: An `IBugAttachment`.
1698 """
1699+ if url:
1700+ if data or filename:
1701+ raise ValueError(
1702+ "Either `url` or `data` / `filename` can be provided."
1703+ )
1704+ else:
1705+ if data is None:
1706+ data = self.getUniqueBytes()
1707+ if filename is None:
1708+ filename = self.getUniqueString()
1709+
1710 if bug is None:
1711 bug = self.makeBug()
1712 elif isinstance(bug, (int, str)):
1713 bug = getUtility(IBugSet).getByNameOrID(str(bug))
1714 if owner is None:
1715 owner = self.makePerson()
1716- if data is None:
1717- data = self.getUniqueBytes()
1718 if description is None:
1719 description = self.getUniqueString()
1720 if comment is None:
1721 comment = self.getUniqueString()
1722- if filename is None:
1723- filename = self.getUniqueString()
1724 # If the default value of is_patch when creating a new
1725 # BugAttachment should ever change, we don't want to interfere
1726 # with that. So, we only override it if our caller explicitly
1727@@ -2610,6 +2619,7 @@ class LaunchpadObjectFactory(ObjectFactory):
1728 data,
1729 comment,
1730 filename,
1731+ url,
1732 content_type=content_type,
1733 description=description,
1734 **other_params,

Subscribers

People subscribed via source and target branches

to status/vote changes: