1 # Copyright (C) 2006-2019 by the Free Software Foundation, Inc.
3 # This file is part of GNU Mailman.
5 # GNU Mailman is free software: you can redistribute it and/or modify it under
6 # the terms of the GNU General Public License as published by the Free
7 # Software Foundation, either version 3 of the License, or (at your option)
10 # GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
11 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12 # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
15 # You should have received a copy of the GNU General Public License along with
16 # GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
18 """Model for mailing lists."""
22 from mailman
.config
import config
23 from mailman
.database
.model
import Model
24 from mailman
.database
.transaction
import dbconnection
25 from mailman
.database
.types
import Enum
, SAUnicode
, SAUnicodeLarge
26 from mailman
.interfaces
.action
import Action
, FilterAction
27 from mailman
.interfaces
.address
import IAddress
28 from mailman
.interfaces
.archiver
import ArchivePolicy
29 from mailman
.interfaces
.autorespond
import ResponseAction
30 from mailman
.interfaces
.bounce
import UnrecognizedBounceDisposition
31 from mailman
.interfaces
.digests
import DigestFrequency
32 from mailman
.interfaces
.domain
import IDomainManager
33 from mailman
.interfaces
.languages
import ILanguageManager
34 from mailman
.interfaces
.mailinglist
import (
35 DMARCMitigateAction
, IAcceptableAlias
, IAcceptableAliasSet
,
36 IHeaderMatch
, IHeaderMatchList
, IListArchiver
, IListArchiverSet
,
37 IMailingList
, Personalization
, ReplyToMunging
, SubscriptionPolicy
)
38 from mailman
.interfaces
.member
import (
39 AlreadySubscribedError
, MemberRole
, MissingPreferredAddressError
,
41 from mailman
.interfaces
.mime
import FilterType
42 from mailman
.interfaces
.nntp
import NewsgroupModeration
43 from mailman
.interfaces
.user
import IUser
44 from mailman
.model
import roster
45 from mailman
.model
.digests
import OneLastDigest
46 from mailman
.model
.member
import Member
47 from mailman
.model
.mime
import ContentFilter
48 from mailman
.model
.preferences
import Preferences
49 from mailman
.utilities
.filesystem
import makedirs
50 from mailman
.utilities
.string
import expand
51 from public
import public
52 from sqlalchemy
import (
53 Boolean
, Column
, DateTime
, Float
, ForeignKey
, Integer
, Interval
,
54 LargeBinary
, PickleType
)
55 from sqlalchemy
.event
import listen
56 from sqlalchemy
.ext
.hybrid
import hybrid_property
57 from sqlalchemy
.orm
import relationship
58 from sqlalchemy
.orm
.exc
import NoResultFound
59 from zope
.component
import getUtility
60 from zope
.event
import notify
61 from zope
.interface
import implementer
69 @implementer(IMailingList
)
70 class MailingList(Model
):
71 """See `IMailingList`."""
73 __tablename__
= 'mailinglist'
75 id = Column(Integer
, primary_key
=True)
77 # XXX denotes attributes that should be part of the public interface but
78 # are currently missing.
81 list_name
= Column(SAUnicode
, index
=True)
82 mail_host
= Column(SAUnicode
, index
=True)
83 _list_id
= Column('list_id', SAUnicode
, index
=True, unique
=True)
84 allow_list_posts
= Column(Boolean
)
85 include_rfc2369_headers
= Column(Boolean
)
86 advertised
= Column(Boolean
)
87 anonymous_list
= Column(Boolean
)
88 # Attributes not directly modifiable via the web u/i
89 created_at
= Column(DateTime
)
90 # Attributes which are directly modifiable via the web u/i. The more
91 # complicated attributes are currently stored as pickles, though that
92 # will change as the schema and implementation is developed.
93 next_request_id
= Column(Integer
)
94 next_digest_number
= Column(Integer
)
95 digest_last_sent_at
= Column(DateTime
)
96 volume
= Column(Integer
)
97 last_post_at
= Column(DateTime
)
98 # Attributes which are directly modifiable via the web u/i. The more
99 # complicated attributes are currently stored as pickles, though that
100 # will change as the schema and implementation is developed.
101 accept_these_nonmembers
= Column(PickleType
) # XXX
102 admin_immed_notify
= Column(Boolean
)
103 admin_notify_mchanges
= Column(Boolean
)
104 administrivia
= Column(Boolean
)
105 archive_policy
= Column(Enum(ArchivePolicy
))
106 # Automatic responses.
107 autoresponse_grace_period
= Column(Interval
)
108 autorespond_owner
= Column(Enum(ResponseAction
))
109 autoresponse_owner_text
= Column(SAUnicode
)
110 autorespond_postings
= Column(Enum(ResponseAction
))
111 autoresponse_postings_text
= Column(SAUnicode
)
112 autorespond_requests
= Column(Enum(ResponseAction
))
113 autoresponse_request_text
= Column(SAUnicode
)
115 filter_action
= Column(Enum(FilterAction
))
116 filter_content
= Column(Boolean
)
117 collapse_alternatives
= Column(Boolean
)
118 convert_html_to_plaintext
= Column(Boolean
)
120 bounce_info_stale_after
= Column(Interval
) # XXX
121 bounce_matching_headers
= Column(SAUnicode
) # XXX
122 bounce_notify_owner_on_disable
= Column(Boolean
) # XXX
123 bounce_notify_owner_on_removal
= Column(Boolean
) # XXX
124 bounce_score_threshold
= Column(Integer
) # XXX
125 bounce_you_are_disabled_warnings
= Column(Integer
) # XXX
126 bounce_you_are_disabled_warnings_interval
= Column(Interval
) # XXX
127 forward_unrecognized_bounces_to
= Column(
128 Enum(UnrecognizedBounceDisposition
))
129 process_bounces
= Column(Boolean
)
131 dmarc_mitigate_action
= Column(Enum(DMARCMitigateAction
))
132 dmarc_mitigate_unconditionally
= Column(Boolean
)
133 dmarc_moderation_notice
= Column(SAUnicodeLarge
)
134 dmarc_wrapped_message_text
= Column(SAUnicodeLarge
)
136 default_member_action
= Column(Enum(Action
))
137 default_nonmember_action
= Column(Enum(Action
))
138 description
= Column(SAUnicode
)
139 digests_enabled
= Column(Boolean
)
140 digest_is_default
= Column(Boolean
)
141 digest_send_periodic
= Column(Boolean
)
142 digest_size_threshold
= Column(Float
)
143 digest_volume_frequency
= Column(Enum(DigestFrequency
))
144 discard_these_nonmembers
= Column(PickleType
)
145 emergency
= Column(Boolean
)
146 encode_ascii_prefixes
= Column(Boolean
)
147 first_strip_reply_to
= Column(Boolean
)
148 forward_auto_discards
= Column(Boolean
)
149 gateway_to_mail
= Column(Boolean
)
150 gateway_to_news
= Column(Boolean
)
151 hold_these_nonmembers
= Column(PickleType
)
152 info
= Column(SAUnicode
)
153 linked_newsgroup
= Column(SAUnicode
)
154 max_days_to_hold
= Column(Integer
)
155 max_message_size
= Column(Integer
)
156 max_num_recipients
= Column(Integer
)
157 member_moderation_notice
= Column(SAUnicode
)
158 # FIXME: There should be no moderator_password
159 moderator_password
= Column(LargeBinary
) # TODO : was RawStr()
160 newsgroup_moderation
= Column(Enum(NewsgroupModeration
))
161 nntp_prefix_subject_too
= Column(Boolean
)
162 nonmember_rejection_notice
= Column(SAUnicode
)
163 obscure_addresses
= Column(Boolean
)
164 owner_chain
= Column(SAUnicode
)
165 owner_pipeline
= Column(SAUnicode
)
166 personalize
= Column(Enum(Personalization
))
167 post_id
= Column(Integer
)
168 posting_chain
= Column(SAUnicode
)
169 posting_pipeline
= Column(SAUnicode
)
170 _preferred_language
= Column('preferred_language', SAUnicode
)
171 display_name
= Column(SAUnicode
)
172 reject_these_nonmembers
= Column(PickleType
)
173 reply_goes_to_list
= Column(Enum(ReplyToMunging
))
174 reply_to_address
= Column(SAUnicode
)
175 require_explicit_destination
= Column(Boolean
)
176 respond_to_post_requests
= Column(Boolean
)
177 member_roster_visibility
= Column(Enum(roster
.RosterVisibility
))
178 scrub_nondigest
= Column(Boolean
)
179 send_goodbye_message
= Column(Boolean
)
180 send_welcome_message
= Column(Boolean
)
181 subject_prefix
= Column(SAUnicode
)
182 subscription_policy
= Column(Enum(SubscriptionPolicy
))
183 topics
= Column(PickleType
)
184 topics_bodylines_limit
= Column(Integer
)
185 topics_enabled
= Column(Boolean
)
186 unsubscription_policy
= Column(Enum(SubscriptionPolicy
))
188 header_matches
= relationship(
189 'HeaderMatch', backref
='mailing_list',
190 cascade
="all, delete-orphan",
191 order_by
="HeaderMatch._position")
193 def __init__(self
, fqdn_listname
):
195 listname
, at
, hostname
= fqdn_listname
.partition('@')
196 assert hostname
, 'Bad list name: {0}'.format(fqdn_listname
)
197 self
.list_name
= listname
198 self
.mail_host
= hostname
199 self
._list
_id
= '{0}.{1}'.format(listname
, hostname
)
200 # For the pending database
201 self
.next_request_id
= 1
202 # We need to set up the rosters. Normally, this method will get called
203 # when the MailingList object is loaded from the database, but when the
204 # constructor is called, SQLAlchemy's `load` event isn't triggered.
205 # Thus we need to set up the rosters explicitly.
207 makedirs(self
.data_path
)
209 def _post_load(self
, *args
):
210 # This hooks up to SQLAlchemy's `load` event.
211 self
.owners
= roster
.OwnerRoster(self
)
212 self
.moderators
= roster
.ModeratorRoster(self
)
213 self
.administrators
= roster
.AdministratorRoster(self
)
214 self
.members
= roster
.MemberRoster(self
)
215 self
.regular_members
= roster
.RegularMemberRoster(self
)
216 self
.digest_members
= roster
.DigestMemberRoster(self
)
217 self
.subscribers
= roster
.Subscribers(self
)
218 self
.nonmembers
= roster
.NonmemberRoster(self
)
221 def __declare_last__(cls
):
222 # SQLAlchemy special directive hook called after mappings are assumed
223 # to be complete. Use this to connect the roster instance creation
224 # method with the SA `load` event.
225 listen(cls
, 'load', cls
._post
_load
)
228 return '<mailing list "{}" at {:#x}>'.format(
229 self
.fqdn_listname
, id(self
))
232 def fqdn_listname(self
):
233 """See `IMailingList`."""
234 return '{}@{}'.format(self
.list_name
, self
.mail_host
)
238 """See `IMailingList`."""
243 """See `IMailingList`."""
244 return getUtility(IDomainManager
)[self
.mail_host
]
248 """See `IMailingList`."""
249 return os
.path
.join(config
.LIST_DATA_DIR
, self
.list_id
)
251 # IMailingListAddresses
254 def posting_address(self
):
255 """See `IMailingList`."""
256 return self
.fqdn_listname
259 def no_reply_address(self
):
260 """See `IMailingList`."""
261 return '{}@{}'.format(config
.mailman
.noreply_address
, self
.mail_host
)
264 def owner_address(self
):
265 """See `IMailingList`."""
266 return '{}-owner@{}'.format(self
.list_name
, self
.mail_host
)
269 def request_address(self
):
270 """See `IMailingList`."""
271 return '{}-request@{}'.format(self
.list_name
, self
.mail_host
)
274 def bounces_address(self
):
275 """See `IMailingList`."""
276 return '{}-bounces@{}'.format(self
.list_name
, self
.mail_host
)
279 def join_address(self
):
280 """See `IMailingList`."""
281 return '{}-join@{}'.format(self
.list_name
, self
.mail_host
)
284 def leave_address(self
):
285 """See `IMailingList`."""
286 return '{}-leave@{}'.format(self
.list_name
, self
.mail_host
)
289 def subscribe_address(self
):
290 """See `IMailingList`."""
291 return '{}-subscribe@{}'.format(self
.list_name
, self
.mail_host
)
294 def unsubscribe_address(self
):
295 """See `IMailingList`."""
296 return '{}-unsubscribe@{}'.format(self
.list_name
, self
.mail_host
)
298 def confirm_address(self
, cookie
):
299 """See `IMailingList`."""
300 local_part
= expand(config
.mta
.verp_confirm_format
, self
, dict(
301 address
='{}-confirm'.format(self
.list_name
),
303 return '{}@{}'.format(local_part
, self
.mail_host
)
306 def preferred_language(self
):
307 """See `IMailingList`."""
308 return getUtility(ILanguageManager
)[self
._preferred
_language
]
310 @preferred_language.setter
311 def preferred_language(self
, language
):
312 """See `IMailingList`."""
313 # Accept both a language code and a `Language` instance.
315 self
._preferred
_language
= language
.code
316 except AttributeError:
317 self
._preferred
_language
= language
320 def send_one_last_digest_to(self
, store
, address
, delivery_mode
):
321 """See `IMailingList`."""
322 digest
= OneLastDigest(self
, address
, delivery_mode
)
327 def last_digest_recipients(self
, store
):
328 """See `IMailingList`."""
329 results
= store
.query(OneLastDigest
).filter(
330 OneLastDigest
.mailing_list
== self
)
331 recipients
= [(digest
.address
, digest
.delivery_mode
)
332 for digest
in results
]
338 def filter_types(self
, store
):
339 """See `IMailingList`."""
340 results
= store
.query(ContentFilter
).filter(
341 ContentFilter
.mailing_list
== self
,
342 ContentFilter
.filter_type
== FilterType
.filter_mime
)
343 for content_filter
in results
:
344 yield content_filter
.filter_pattern
348 def filter_types(self
, store
, sequence
):
349 """See `IMailingList`."""
350 # First, delete all existing MIME type filter patterns.
351 results
= store
.query(ContentFilter
).filter(
352 ContentFilter
.mailing_list
== self
,
353 ContentFilter
.filter_type
== FilterType
.filter_mime
)
355 # Now add all the new filter types.
356 for mime_type
in sequence
:
357 content_filter
= ContentFilter(
358 self
, mime_type
, FilterType
.filter_mime
)
359 store
.add(content_filter
)
363 def pass_types(self
, store
):
364 """See `IMailingList`."""
365 results
= store
.query(ContentFilter
).filter(
366 ContentFilter
.mailing_list
== self
,
367 ContentFilter
.filter_type
== FilterType
.pass_mime
)
368 for content_filter
in results
:
369 yield content_filter
.filter_pattern
373 def pass_types(self
, store
, sequence
):
374 """See `IMailingList`."""
375 # First, delete all existing MIME type pass patterns.
376 results
= store
.query(ContentFilter
).filter(
377 ContentFilter
.mailing_list
== self
,
378 ContentFilter
.filter_type
== FilterType
.pass_mime
)
380 # Now add all the new filter types.
381 for mime_type
in sequence
:
382 content_filter
= ContentFilter(
383 self
, mime_type
, FilterType
.pass_mime
)
384 store
.add(content_filter
)
388 def filter_extensions(self
, store
):
389 """See `IMailingList`."""
390 results
= store
.query(ContentFilter
).filter(
391 ContentFilter
.mailing_list
== self
,
392 ContentFilter
.filter_type
== FilterType
.filter_extension
)
393 for content_filter
in results
:
394 yield content_filter
.filter_pattern
396 @filter_extensions.setter
398 def filter_extensions(self
, store
, sequence
):
399 """See `IMailingList`."""
400 # First, delete all existing file extensions filter patterns.
401 results
= store
.query(ContentFilter
).filter(
402 ContentFilter
.mailing_list
== self
,
403 ContentFilter
.filter_type
== FilterType
.filter_extension
)
405 # Now add all the new filter types.
406 for mime_type
in sequence
:
407 content_filter
= ContentFilter(
408 self
, mime_type
, FilterType
.filter_extension
)
409 store
.add(content_filter
)
413 def pass_extensions(self
, store
):
414 """See `IMailingList`."""
415 results
= store
.query(ContentFilter
).filter(
416 ContentFilter
.mailing_list
== self
,
417 ContentFilter
.filter_type
== FilterType
.pass_extension
)
418 for content_filter
in results
:
419 yield content_filter
.filter_pattern
421 @pass_extensions.setter
423 def pass_extensions(self
, store
, sequence
):
424 """See `IMailingList`."""
425 # First, delete all existing file extensions pass patterns.
426 results
= store
.query(ContentFilter
).filter(
427 ContentFilter
.mailing_list
== self
,
428 ContentFilter
.filter_type
== FilterType
.pass_extension
)
430 # Now add all the new filter types.
431 for mime_type
in sequence
:
432 content_filter
= ContentFilter(
433 self
, mime_type
, FilterType
.pass_extension
)
434 store
.add(content_filter
)
436 def get_roster(self
, role
):
437 """See `IMailingList`."""
438 if role
is MemberRole
.member
:
440 elif role
is MemberRole
.owner
:
442 elif role
is MemberRole
.moderator
:
443 return self
.moderators
444 elif role
is MemberRole
.nonmember
:
445 return self
.nonmembers
447 raise ValueError('Undefined MemberRole: {}'.format(role
))
449 def _get_subscriber(self
, store
, subscriber
, role
):
450 """Get some information about a user/address.
452 Returns a 2-tuple of (member, email) for the given subscriber. If the
453 subscriber is is not an ``IAddress`` or ``IUser``, then a 2-tuple of
454 (None, None) is returned. If the subscriber is not already
455 subscribed, then (None, email) is returned. If the subscriber is an
456 ``IUser`` and does not have a preferred address, (member, None) is
461 if IAddress
.providedBy(subscriber
):
462 member
= store
.query(Member
).filter(
464 Member
.list_id
== self
._list
_id
,
465 Member
._address
== subscriber
).first()
466 email
= subscriber
.email
467 elif IUser
.providedBy(subscriber
):
468 if subscriber
.preferred_address
is None:
469 raise MissingPreferredAddressError(subscriber
)
470 email
= subscriber
.preferred_address
.email
471 member
= store
.query(Member
).filter(
473 Member
.list_id
== self
._list
_id
,
474 Member
._user
== subscriber
).first()
478 def is_subscribed(self
, store
, subscriber
, role
=MemberRole
.member
):
479 """See `IMailingList`."""
480 member
, email
= self
._get
_subscriber
(store
, subscriber
, role
)
481 return member
is not None
484 def subscribe(self
, store
, subscriber
, role
=MemberRole
.member
):
485 """See `IMailingList`."""
486 member
, email
= self
._get
_subscriber
(store
, subscriber
, role
)
487 if member
is not None:
488 raise AlreadySubscribedError(self
.fqdn_listname
, email
, role
)
489 member
= Member(role
=role
,
490 list_id
=self
._list
_id
,
491 subscriber
=subscriber
)
492 member
.preferences
= Preferences()
494 notify(SubscriptionEvent(self
, member
))
499 @implementer(IAcceptableAlias
)
500 class AcceptableAlias(Model
):
501 """See `IAcceptableAlias`."""
503 __tablename__
= 'acceptablealias'
505 id = Column(Integer
, primary_key
=True)
507 mailing_list_id
= Column(
508 Integer
, ForeignKey('mailinglist.id'),
509 index
=True, nullable
=False)
510 mailing_list
= relationship('MailingList', backref
='acceptablealias')
511 alias
= Column(SAUnicode
, index
=True, nullable
=False)
513 def __init__(self
, mailing_list
, alias
):
515 self
.mailing_list
= mailing_list
520 @implementer(IAcceptableAliasSet
)
521 class AcceptableAliasSet
:
522 """See `IAcceptableAliasSet`."""
524 def __init__(self
, mailing_list
):
525 self
._mailing
_list
= mailing_list
528 def clear(self
, store
):
529 """See `IAcceptableAliasSet`."""
530 store
.query(AcceptableAlias
).filter(
531 AcceptableAlias
.mailing_list
== self
._mailing
_list
).delete()
534 def add(self
, store
, alias
):
535 if not (alias
.startswith('^') or '@' in alias
):
536 raise ValueError(alias
)
537 alias
= AcceptableAlias(self
._mailing
_list
, alias
.lower())
541 def remove(self
, store
, alias
):
542 store
.query(AcceptableAlias
).filter(
543 AcceptableAlias
.mailing_list
== self
._mailing
_list
,
544 AcceptableAlias
.alias
== alias
.lower()).delete()
548 def aliases(self
, store
):
549 aliases
= store
.query(AcceptableAlias
).filter(
550 AcceptableAlias
.mailing_list_id
== self
._mailing
_list
.id)
551 for alias
in aliases
:
556 @implementer(IListArchiver
)
557 class ListArchiver(Model
):
558 """See `IListArchiver`."""
560 __tablename__
= 'listarchiver'
562 id = Column(Integer
, primary_key
=True)
564 mailing_list_id
= Column(
565 Integer
, ForeignKey('mailinglist.id'),
566 index
=True, nullable
=False)
567 mailing_list
= relationship('MailingList')
569 name
= Column(SAUnicode
, nullable
=False)
570 _is_enabled
= Column(Boolean
)
572 def __init__(self
, mailing_list
, archiver_name
, system_archiver
):
573 self
.mailing_list
= mailing_list
574 self
.name
= archiver_name
575 self
._is
_enabled
= system_archiver
.is_enabled
578 def system_archiver(self
):
579 for archiver
in config
.archivers
: # pragma: no branch
580 if archiver
.name
== self
.name
:
582 raise AssertionError('Archiver not found: {}'.format(self
.name
))
585 def is_enabled(self
):
586 return self
.system_archiver
.is_enabled
and self
._is
_enabled
589 def is_enabled(self
, value
):
590 self
._is
_enabled
= value
594 @implementer(IListArchiverSet
)
595 class ListArchiverSet
:
597 def __init__(self
, store
, mailing_list
):
598 self
._mailing
_list
= mailing_list
599 system_archivers
= {}
600 for archiver
in config
.archivers
:
601 system_archivers
[archiver
.name
] = archiver
602 # Add any system enabled archivers which aren't already associated
603 # with the mailing list.
604 for archiver_name
in system_archivers
:
605 exists
= store
.query(ListArchiver
).filter(
606 ListArchiver
.mailing_list
== mailing_list
,
607 ListArchiver
.name
== archiver_name
).one_or_none()
609 store
.add(ListArchiver(mailing_list
, archiver_name
,
610 system_archivers
[archiver_name
]))
614 def archivers(self
, store
):
615 entries
= store
.query(ListArchiver
).filter(
616 ListArchiver
.mailing_list
== self
._mailing
_list
)
620 def get(self
, store
, archiver_name
):
621 return store
.query(ListArchiver
).filter(
622 ListArchiver
.mailing_list
== self
._mailing
_list
,
623 ListArchiver
.name
== archiver_name
).one_or_none()
627 @implementer(IHeaderMatch
)
628 class HeaderMatch(Model
):
629 """See `IHeaderMatch`."""
631 __tablename__
= 'headermatch'
633 id = Column(Integer
, primary_key
=True)
635 mailing_list_id
= Column(
637 ForeignKey('mailinglist.id'),
638 index
=True, nullable
=False)
640 _position
= Column('position', Integer
, index
=True, default
=0)
641 header
= Column(SAUnicode
)
642 pattern
= Column(SAUnicode
)
643 chain
= Column(SAUnicode
, nullable
=True)
645 def __init__(self
, **kw
):
646 position
= kw
.pop('position', None)
647 if position
is not None:
648 kw
['_position'] = position
649 super().__init
__(**kw
)
653 """See `IHeaderMatch`."""
654 return self
._position
658 def position(self
, store
, value
):
659 """See `IHeaderMatch`."""
661 raise ValueError('Negative indexes are not supported')
662 if value
== self
.position
:
665 existing_count
= store
.query(HeaderMatch
).filter(
666 HeaderMatch
.mailing_list
== self
.mailing_list
).count()
667 if value
>= existing_count
:
669 'There are {count} header matches for this list, '
670 'the new position cannot be {count} or higher'.format(
671 count
=existing_count
))
672 if value
< self
.position
:
673 # Moving up: header matches between the new position and the
674 # current one must be moved down the list to make room. Those
675 # after the current position must not be changed.
676 for header_match
in store
.query(HeaderMatch
).filter(
677 HeaderMatch
.mailing_list
== self
.mailing_list
,
678 HeaderMatch
.position
>= value
,
679 HeaderMatch
.position
< self
.position
):
680 header_match
._position
= header_match
.position
+ 1
681 elif value
> self
.position
:
682 # Moving down: header matches between the current position and the
683 # new one must be moved up the list to make room. Those after the
684 # new position must not be changed.
685 for header_match
in store
.query(HeaderMatch
).filter(
686 HeaderMatch
.mailing_list
== self
.mailing_list
,
687 HeaderMatch
.position
> self
.position
,
688 HeaderMatch
.position
<= value
):
689 header_match
._position
= header_match
.position
- 1
690 self
._position
= value
694 @implementer(IHeaderMatchList
)
695 class HeaderMatchList
:
696 """See `IHeaderMatchList`."""
698 # All write operations must mark the mailing list's header_matches
699 # collection as expired:
700 # http://docs.sqlalchemy.org/en/latest/orm/session_state_management.html#refreshing-expiring
702 def __init__(self
, mailing_list
):
703 self
._mailing
_list
= mailing_list
706 def clear(self
, store
):
707 """See `IHeaderMatchList`."""
708 # http://docs.sqlalchemy.org/en/latest/orm/session_basics.html#deleting-from-collections
709 del self
._mailing
_list
.header_matches
[:]
712 def append(self
, store
, header
, pattern
, chain
=None):
713 header
= header
.lower()
714 existing
= store
.query(HeaderMatch
).filter(
715 HeaderMatch
.mailing_list
== self
._mailing
_list
,
716 HeaderMatch
.header
== header
,
717 HeaderMatch
.pattern
== pattern
).count()
719 raise ValueError('Pattern already exists')
720 last_position
= store
.query(HeaderMatch
.position
).filter(
721 HeaderMatch
.mailing_list
== self
._mailing
_list
722 ).order_by(HeaderMatch
.position
.desc()).limit(1).scalar()
723 if last_position
is None:
725 header_match
= HeaderMatch(
726 mailing_list
=self
._mailing
_list
,
727 header
=header
, pattern
=pattern
, chain
=chain
,
728 position
=last_position
+ 1)
729 store
.add(header_match
)
730 store
.expire(self
._mailing
_list
, ['header_matches'])
733 def insert(self
, store
, index
, header
, pattern
, chain
=None):
734 self
.append(header
, pattern
, chain
)
735 # Get the header match that was just added.
736 header_match
= store
.query(HeaderMatch
).filter(
737 HeaderMatch
.mailing_list
== self
._mailing
_list
,
738 HeaderMatch
.header
== header
.lower(),
739 HeaderMatch
.pattern
== pattern
,
740 HeaderMatch
.chain
== chain
).one()
741 header_match
.position
= index
742 store
.expire(self
._mailing
_list
, ['header_matches'])
745 def remove(self
, store
, header
, pattern
):
746 header
= header
.lower()
747 # Query.delete() has many caveats, don't use it here:
748 # http://docs.sqlalchemy.org/en/rel_1_0/orm/query.html#sqlalchemy.orm.query.Query.delete
750 existing
= store
.query(HeaderMatch
).filter(
751 HeaderMatch
.mailing_list
== self
._mailing
_list
,
752 HeaderMatch
.header
== header
,
753 HeaderMatch
.pattern
== pattern
).one()
754 except NoResultFound
:
755 raise ValueError('Pattern does not exist')
757 store
.delete(existing
)
758 self
._restore
_position
_sequence
()
759 store
.expire(self
._mailing
_list
, ['header_matches'])
762 def __getitem__(self
, store
, index
):
764 index
= len(self
) + index
766 return store
.query(HeaderMatch
).filter(
767 HeaderMatch
.mailing_list
== self
._mailing
_list
,
768 HeaderMatch
.position
== index
).one()
769 except NoResultFound
:
773 def __delitem__(self
, store
, index
):
775 existing
= store
.query(HeaderMatch
).filter(
776 HeaderMatch
.mailing_list
== self
._mailing
_list
,
777 HeaderMatch
.position
== index
).one()
778 except NoResultFound
:
781 store
.delete(existing
)
782 self
._restore
_position
_sequence
()
783 store
.expire(self
._mailing
_list
, ['header_matches'])
786 def __len__(self
, store
):
787 return store
.query(HeaderMatch
).filter(
788 HeaderMatch
.mailing_list
== self
._mailing
_list
).count()
791 def __iter__(self
, store
):
792 yield from store
.query(HeaderMatch
).filter(
793 HeaderMatch
.mailing_list
== self
._mailing
_list
794 ).order_by(HeaderMatch
.position
)
797 def _restore_position_sequence(self
, store
):
798 # Restore a continuous position sequence for this mailing list's
801 # The header match positions may not be continuous after deleting an
802 # item. It won't prevent this component from working properly, but
803 # it's cleaner to restore a continuous sequence.
804 for position
, match
in enumerate(store
.query(HeaderMatch
).filter(
805 HeaderMatch
.mailing_list
== self
._mailing
_list
806 ).order_by(HeaderMatch
.position
)):
807 match
._position
= position
808 store
.expire(self
._mailing
_list
, ['header_matches'])