Add per list member roster visibility option
[mailman.git] / src / mailman / model / mailinglist.py
blobcf6cbec9e9d254b9bfbd030b33501020fe18ffb2
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)
8 # any later version.
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
13 # more details.
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."""
20 import os
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,
40 SubscriptionEvent)
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
64 SPACE = ' '
65 UNDERSCORE = '_'
68 @public
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.
80 # List identity
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)
114 # Content filters.
115 filter_action = Column(Enum(FilterAction))
116 filter_content = Column(Boolean)
117 collapse_alternatives = Column(Boolean)
118 convert_html_to_plaintext = Column(Boolean)
119 # Bounces.
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)
130 # DMARC
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)
135 # Miscellaneous
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))
187 # ORM relationships.
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):
194 super().__init__()
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.
206 self._post_load()
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)
220 @classmethod
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)
227 def __repr__(self):
228 return '<mailing list "{}" at {:#x}>'.format(
229 self.fqdn_listname, id(self))
231 @property
232 def fqdn_listname(self):
233 """See `IMailingList`."""
234 return '{}@{}'.format(self.list_name, self.mail_host)
236 @property
237 def list_id(self):
238 """See `IMailingList`."""
239 return self._list_id
241 @property
242 def domain(self):
243 """See `IMailingList`."""
244 return getUtility(IDomainManager)[self.mail_host]
246 @property
247 def data_path(self):
248 """See `IMailingList`."""
249 return os.path.join(config.LIST_DATA_DIR, self.list_id)
251 # IMailingListAddresses
253 @property
254 def posting_address(self):
255 """See `IMailingList`."""
256 return self.fqdn_listname
258 @property
259 def no_reply_address(self):
260 """See `IMailingList`."""
261 return '{}@{}'.format(config.mailman.noreply_address, self.mail_host)
263 @property
264 def owner_address(self):
265 """See `IMailingList`."""
266 return '{}-owner@{}'.format(self.list_name, self.mail_host)
268 @property
269 def request_address(self):
270 """See `IMailingList`."""
271 return '{}-request@{}'.format(self.list_name, self.mail_host)
273 @property
274 def bounces_address(self):
275 """See `IMailingList`."""
276 return '{}-bounces@{}'.format(self.list_name, self.mail_host)
278 @property
279 def join_address(self):
280 """See `IMailingList`."""
281 return '{}-join@{}'.format(self.list_name, self.mail_host)
283 @property
284 def leave_address(self):
285 """See `IMailingList`."""
286 return '{}-leave@{}'.format(self.list_name, self.mail_host)
288 @property
289 def subscribe_address(self):
290 """See `IMailingList`."""
291 return '{}-subscribe@{}'.format(self.list_name, self.mail_host)
293 @property
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),
302 cookie=cookie))
303 return '{}@{}'.format(local_part, self.mail_host)
305 @property
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.
314 try:
315 self._preferred_language = language.code
316 except AttributeError:
317 self._preferred_language = language
319 @dbconnection
320 def send_one_last_digest_to(self, store, address, delivery_mode):
321 """See `IMailingList`."""
322 digest = OneLastDigest(self, address, delivery_mode)
323 store.add(digest)
325 @property
326 @dbconnection
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]
333 results.delete()
334 return recipients
336 @property
337 @dbconnection
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
346 @filter_types.setter
347 @dbconnection
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)
354 results.delete()
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)
361 @property
362 @dbconnection
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
371 @pass_types.setter
372 @dbconnection
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)
379 results.delete()
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)
386 @property
387 @dbconnection
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
397 @dbconnection
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)
404 results.delete()
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)
411 @property
412 @dbconnection
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
422 @dbconnection
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)
429 results.delete()
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:
439 return self.members
440 elif role is MemberRole.owner:
441 return self.owners
442 elif role is MemberRole.moderator:
443 return self.moderators
444 elif role is MemberRole.nonmember:
445 return self.nonmembers
446 else:
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
457 returned.
459 member = None
460 email = None
461 if IAddress.providedBy(subscriber):
462 member = store.query(Member).filter(
463 Member.role == role,
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(
472 Member.role == role,
473 Member.list_id == self._list_id,
474 Member._user == subscriber).first()
475 return member, email
477 @dbconnection
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
483 @dbconnection
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()
493 store.add(member)
494 notify(SubscriptionEvent(self, member))
495 return member
498 @public
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):
514 super().__init__()
515 self.mailing_list = mailing_list
516 self.alias = alias
519 @public
520 @implementer(IAcceptableAliasSet)
521 class AcceptableAliasSet:
522 """See `IAcceptableAliasSet`."""
524 def __init__(self, mailing_list):
525 self._mailing_list = mailing_list
527 @dbconnection
528 def clear(self, store):
529 """See `IAcceptableAliasSet`."""
530 store.query(AcceptableAlias).filter(
531 AcceptableAlias.mailing_list == self._mailing_list).delete()
533 @dbconnection
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())
538 store.add(alias)
540 @dbconnection
541 def remove(self, store, alias):
542 store.query(AcceptableAlias).filter(
543 AcceptableAlias.mailing_list == self._mailing_list,
544 AcceptableAlias.alias == alias.lower()).delete()
546 @property
547 @dbconnection
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:
552 yield alias.alias
555 @public
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
577 @property
578 def system_archiver(self):
579 for archiver in config.archivers: # pragma: no branch
580 if archiver.name == self.name:
581 return archiver
582 raise AssertionError('Archiver not found: {}'.format(self.name))
584 @property
585 def is_enabled(self):
586 return self.system_archiver.is_enabled and self._is_enabled
588 @is_enabled.setter
589 def is_enabled(self, value):
590 self._is_enabled = value
593 @public
594 @implementer(IListArchiverSet)
595 class ListArchiverSet:
596 @dbconnection
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()
608 if exists is None:
609 store.add(ListArchiver(mailing_list, archiver_name,
610 system_archivers[archiver_name]))
612 @property
613 @dbconnection
614 def archivers(self, store):
615 entries = store.query(ListArchiver).filter(
616 ListArchiver.mailing_list == self._mailing_list)
617 yield from entries
619 @dbconnection
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()
626 @public
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(
636 Integer,
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)
651 @hybrid_property
652 def position(self):
653 """See `IHeaderMatch`."""
654 return self._position
656 @position.setter
657 @dbconnection
658 def position(self, store, value):
659 """See `IHeaderMatch`."""
660 if value < 0:
661 raise ValueError('Negative indexes are not supported')
662 if value == self.position:
663 # Nothing to do.
664 return
665 existing_count = store.query(HeaderMatch).filter(
666 HeaderMatch.mailing_list == self.mailing_list).count()
667 if value >= existing_count:
668 raise ValueError(
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
693 @public
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
705 @dbconnection
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[:]
711 @dbconnection
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()
718 if existing > 0:
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:
724 last_position = -1
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'])
732 @dbconnection
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'])
744 @dbconnection
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
749 try:
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')
756 else:
757 store.delete(existing)
758 self._restore_position_sequence()
759 store.expire(self._mailing_list, ['header_matches'])
761 @dbconnection
762 def __getitem__(self, store, index):
763 if index < 0:
764 index = len(self) + index
765 try:
766 return store.query(HeaderMatch).filter(
767 HeaderMatch.mailing_list == self._mailing_list,
768 HeaderMatch.position == index).one()
769 except NoResultFound:
770 raise IndexError
772 @dbconnection
773 def __delitem__(self, store, index):
774 try:
775 existing = store.query(HeaderMatch).filter(
776 HeaderMatch.mailing_list == self._mailing_list,
777 HeaderMatch.position == index).one()
778 except NoResultFound:
779 raise IndexError
780 else:
781 store.delete(existing)
782 self._restore_position_sequence()
783 store.expire(self._mailing_list, ['header_matches'])
785 @dbconnection
786 def __len__(self, store):
787 return store.query(HeaderMatch).filter(
788 HeaderMatch.mailing_list == self._mailing_list).count()
790 @dbconnection
791 def __iter__(self, store):
792 yield from store.query(HeaderMatch).filter(
793 HeaderMatch.mailing_list == self._mailing_list
794 ).order_by(HeaderMatch.position)
796 @dbconnection
797 def _restore_position_sequence(self, store):
798 # Restore a continuous position sequence for this mailing list's
799 # header matches.
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'])