1# Copyright (C) 2006-2020 by the Free Software Foundation, Inc. 2# 3# This file is part of GNU Mailman. 4# 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. 9# 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. 14# 15# You should have received a copy of the GNU General Public License along with 16# GNU Mailman. If not, see <https://www.gnu.org/licenses/>. 17 18"""Model for mailing lists.""" 19 20import os 21 22from mailman.config import config 23from mailman.database.model import Model 24from mailman.database.transaction import dbconnection 25from mailman.database.types import Enum, SAUnicode, SAUnicodeLarge 26from mailman.interfaces.action import Action, FilterAction 27from mailman.interfaces.address import IAddress, InvalidEmailAddressError 28from mailman.interfaces.archiver import ArchivePolicy 29from mailman.interfaces.autorespond import ResponseAction 30from mailman.interfaces.bans import IBanManager 31from mailman.interfaces.bounce import UnrecognizedBounceDisposition 32from mailman.interfaces.digests import DigestFrequency 33from mailman.interfaces.domain import IDomainManager 34from mailman.interfaces.languages import ILanguageManager 35from mailman.interfaces.mailinglist import ( 36 DMARCMitigateAction, IAcceptableAlias, IAcceptableAliasSet, 37 IHeaderMatch, IHeaderMatchList, IListArchiver, IListArchiverSet, 38 IMailingList, Personalization, ReplyToMunging, SubscriptionPolicy) 39from mailman.interfaces.member import ( 40 AlreadySubscribedError, MemberRole, MembershipIsBannedError, 41 MissingPreferredAddressError, SubscriptionEvent) 42from mailman.interfaces.mime import FilterType 43from mailman.interfaces.nntp import NewsgroupModeration 44from mailman.interfaces.user import IUser 45from mailman.model import roster 46from mailman.model.digests import OneLastDigest 47from mailman.model.member import Member 48from mailman.model.mime import ContentFilter 49from mailman.model.preferences import Preferences 50from mailman.utilities.filesystem import makedirs 51from mailman.utilities.string import expand 52from public import public 53from sqlalchemy import ( 54 Boolean, Column, DateTime, Float, ForeignKey, Integer, Interval, 55 LargeBinary, PickleType) 56from sqlalchemy.event import listen 57from sqlalchemy.ext.hybrid import hybrid_property 58from sqlalchemy.ext.mutable import MutableList 59from sqlalchemy.orm import relationship 60from sqlalchemy.orm.exc import NoResultFound 61from zope.component import getUtility 62from zope.event import notify 63from zope.interface import implementer 64 65 66SPACE = ' ' 67UNDERSCORE = '_' 68 69 70@public 71@implementer(IMailingList) 72class MailingList(Model): 73 """See `IMailingList`.""" 74 75 __tablename__ = 'mailinglist' 76 77 id = Column(Integer, primary_key=True) 78 79 # XXX denotes attributes that should be part of the public interface but 80 # are currently missing. 81 82 # List identity 83 list_name = Column(SAUnicode, index=True) 84 mail_host = Column(SAUnicode, index=True) 85 _list_id = Column('list_id', SAUnicode, index=True, unique=True) 86 allow_list_posts = Column(Boolean) 87 include_rfc2369_headers = Column(Boolean) 88 advertised = Column(Boolean) 89 anonymous_list = Column(Boolean) 90 # Attributes not directly modifiable via the web u/i 91 created_at = Column(DateTime) 92 # Attributes which are directly modifiable via the web u/i. The more 93 # complicated attributes are currently stored as pickles, though that 94 # will change as the schema and implementation is developed. 95 next_request_id = Column(Integer) 96 next_digest_number = Column(Integer) 97 digest_last_sent_at = Column(DateTime) 98 volume = Column(Integer) 99 last_post_at = Column(DateTime) 100 # Attributes which are directly modifiable via the web u/i. The more 101 # complicated attributes are currently stored as pickles, though that 102 # will change as the schema and implementation is developed. 103 accept_these_nonmembers = Column(MutableList.as_mutable(PickleType)) # XXX 104 admin_immed_notify = Column(Boolean) 105 admin_notify_mchanges = Column(Boolean) 106 administrivia = Column(Boolean) 107 archive_policy = Column(Enum(ArchivePolicy)) 108 # Automatic responses. 109 autoresponse_grace_period = Column(Interval) 110 autorespond_owner = Column(Enum(ResponseAction)) 111 autoresponse_owner_text = Column(SAUnicode) 112 autorespond_postings = Column(Enum(ResponseAction)) 113 autoresponse_postings_text = Column(SAUnicode) 114 autorespond_requests = Column(Enum(ResponseAction)) 115 autoresponse_request_text = Column(SAUnicode) 116 # Content filters. 117 filter_action = Column(Enum(FilterAction)) 118 filter_content = Column(Boolean) 119 collapse_alternatives = Column(Boolean) 120 convert_html_to_plaintext = Column(Boolean) 121 # Bounces. 122 bounce_info_stale_after = Column(Interval) 123 bounce_matching_headers = Column(SAUnicode) # XXX 124 bounce_notify_owner_on_disable = Column(Boolean) 125 bounce_notify_owner_on_removal = Column(Boolean) 126 bounce_score_threshold = Column(Integer) 127 bounce_you_are_disabled_warnings = Column(Integer) 128 bounce_you_are_disabled_warnings_interval = Column(Interval) 129 forward_unrecognized_bounces_to = Column( 130 Enum(UnrecognizedBounceDisposition)) 131 process_bounces = Column(Boolean) 132 # DMARC 133 dmarc_mitigate_action = Column(Enum(DMARCMitigateAction)) 134 dmarc_mitigate_unconditionally = Column(Boolean) 135 dmarc_moderation_notice = Column(SAUnicodeLarge) 136 dmarc_wrapped_message_text = Column(SAUnicodeLarge) 137 # Miscellaneous 138 default_member_action = Column(Enum(Action)) 139 default_nonmember_action = Column(Enum(Action)) 140 description = Column(SAUnicode) 141 digests_enabled = Column(Boolean) 142 digest_is_default = Column(Boolean) 143 digest_send_periodic = Column(Boolean) 144 digest_size_threshold = Column(Float) 145 digest_volume_frequency = Column(Enum(DigestFrequency)) 146 discard_these_nonmembers = Column(MutableList.as_mutable(PickleType)) 147 emergency = Column(Boolean) 148 encode_ascii_prefixes = Column(Boolean) 149 first_strip_reply_to = Column(Boolean) 150 forward_auto_discards = Column(Boolean) 151 gateway_to_mail = Column(Boolean) 152 gateway_to_news = Column(Boolean) 153 hold_these_nonmembers = Column(MutableList.as_mutable(PickleType)) 154 info = Column(SAUnicode) 155 linked_newsgroup = Column(SAUnicode) 156 max_days_to_hold = Column(Integer) 157 max_message_size = Column(Integer) 158 max_num_recipients = Column(Integer) 159 member_moderation_notice = Column(SAUnicode) 160 # FIXME: There should be no moderator_password 161 moderator_password = Column(LargeBinary) # TODO : was RawStr() 162 newsgroup_moderation = Column(Enum(NewsgroupModeration)) 163 nntp_prefix_subject_too = Column(Boolean) 164 nonmember_rejection_notice = Column(SAUnicode) 165 obscure_addresses = Column(Boolean) 166 owner_chain = Column(SAUnicode) 167 owner_pipeline = Column(SAUnicode) 168 personalize = Column(Enum(Personalization)) 169 post_id = Column(Integer) 170 posting_chain = Column(SAUnicode) 171 posting_pipeline = Column(SAUnicode) 172 _preferred_language = Column('preferred_language', SAUnicode) 173 display_name = Column(SAUnicode) 174 reject_these_nonmembers = Column(MutableList.as_mutable(PickleType)) 175 reply_goes_to_list = Column(Enum(ReplyToMunging)) 176 reply_to_address = Column(SAUnicode) 177 require_explicit_destination = Column(Boolean) 178 respond_to_post_requests = Column(Boolean) 179 member_roster_visibility = Column(Enum(roster.RosterVisibility)) 180 scrub_nondigest = Column(Boolean) 181 send_goodbye_message = Column(Boolean) 182 send_welcome_message = Column(Boolean) 183 subject_prefix = Column(SAUnicode) 184 subscription_policy = Column(Enum(SubscriptionPolicy)) 185 topics = Column(PickleType) 186 topics_bodylines_limit = Column(Integer) 187 topics_enabled = Column(Boolean) 188 unsubscription_policy = Column(Enum(SubscriptionPolicy)) 189 usenet_watermark = Column(Integer) 190 # ORM relationships. 191 header_matches = relationship( 192 'HeaderMatch', backref='mailing_list', 193 cascade="all, delete-orphan", 194 order_by="HeaderMatch._position") 195 196 def __init__(self, fqdn_listname): 197 super().__init__() 198 listname, at, hostname = fqdn_listname.partition('@') 199 assert hostname, 'Bad list name: {0}'.format(fqdn_listname) 200 self.list_name = listname 201 self.mail_host = hostname 202 self._list_id = '{0}.{1}'.format(listname, hostname) 203 # For the pending database 204 self.next_request_id = 1 205 # We need to set up the rosters. Normally, this method will get called 206 # when the MailingList object is loaded from the database, but when the 207 # constructor is called, SQLAlchemy's `load` event isn't triggered. 208 # Thus we need to set up the rosters explicitly. 209 self._post_load() 210 makedirs(self.data_path) 211 212 def _post_load(self, *args): 213 # This hooks up to SQLAlchemy's `load` event. 214 self.owners = roster.OwnerRoster(self) 215 self.moderators = roster.ModeratorRoster(self) 216 self.administrators = roster.AdministratorRoster(self) 217 self.members = roster.MemberRoster(self) 218 self.regular_members = roster.RegularMemberRoster(self) 219 self.digest_members = roster.DigestMemberRoster(self) 220 self.subscribers = roster.Subscribers(self) 221 self.nonmembers = roster.NonmemberRoster(self) 222 223 @classmethod 224 def __declare_last__(cls): 225 # SQLAlchemy special directive hook called after mappings are assumed 226 # to be complete. Use this to connect the roster instance creation 227 # method with the SA `load` event. 228 listen(cls, 'load', cls._post_load) 229 230 def __repr__(self): 231 return '<mailing list "{}" at {:#x}>'.format( 232 self.fqdn_listname, id(self)) 233 234 @property 235 def fqdn_listname(self): 236 """See `IMailingList`.""" 237 return '{}@{}'.format(self.list_name, self.mail_host) 238 239 @property 240 def list_id(self): 241 """See `IMailingList`.""" 242 return self._list_id 243 244 @property 245 def domain(self): 246 """See `IMailingList`.""" 247 return getUtility(IDomainManager)[self.mail_host] 248 249 @property 250 def data_path(self): 251 """See `IMailingList`.""" 252 return os.path.join(config.LIST_DATA_DIR, self.list_id) 253 254 # IMailingListAddresses 255 256 @property 257 def posting_address(self): 258 """See `IMailingList`.""" 259 return self.fqdn_listname 260 261 @property 262 def no_reply_address(self): 263 """See `IMailingList`.""" 264 return '{}@{}'.format(config.mailman.noreply_address, self.mail_host) 265 266 @property 267 def owner_address(self): 268 """See `IMailingList`.""" 269 return '{}-owner@{}'.format(self.list_name, self.mail_host) 270 271 @property 272 def request_address(self): 273 """See `IMailingList`.""" 274 return '{}-request@{}'.format(self.list_name, self.mail_host) 275 276 @property 277 def bounces_address(self): 278 """See `IMailingList`.""" 279 return '{}-bounces@{}'.format(self.list_name, self.mail_host) 280 281 @property 282 def join_address(self): 283 """See `IMailingList`.""" 284 return '{}-join@{}'.format(self.list_name, self.mail_host) 285 286 @property 287 def leave_address(self): 288 """See `IMailingList`.""" 289 return '{}-leave@{}'.format(self.list_name, self.mail_host) 290 291 @property 292 def subscribe_address(self): 293 """See `IMailingList`.""" 294 return '{}-subscribe@{}'.format(self.list_name, self.mail_host) 295 296 @property 297 def unsubscribe_address(self): 298 """See `IMailingList`.""" 299 return '{}-unsubscribe@{}'.format(self.list_name, self.mail_host) 300 301 def confirm_address(self, cookie): 302 """See `IMailingList`.""" 303 local_part = expand(config.mta.verp_confirm_format, self, dict( 304 address='{}-confirm'.format(self.list_name), 305 cookie=cookie)) 306 return '{}@{}'.format(local_part, self.mail_host) 307 308 @property 309 def preferred_language(self): 310 """See `IMailingList`.""" 311 return getUtility(ILanguageManager)[self._preferred_language] 312 313 @preferred_language.setter 314 def preferred_language(self, language): 315 """See `IMailingList`.""" 316 # Accept both a language code and a `Language` instance. 317 try: 318 self._preferred_language = language.code 319 except AttributeError: 320 self._preferred_language = language 321 322 @dbconnection 323 def send_one_last_digest_to(self, store, address, delivery_mode): 324 """See `IMailingList`.""" 325 digest = OneLastDigest(self, address, delivery_mode) 326 store.add(digest) 327 328 @property 329 @dbconnection 330 def last_digest_recipients(self, store): 331 """See `IMailingList`.""" 332 results = store.query(OneLastDigest).filter( 333 OneLastDigest.mailing_list == self) 334 recipients = [(digest.address, digest.delivery_mode) 335 for digest in results] 336 results.delete() 337 return recipients 338 339 @property 340 @dbconnection 341 def filter_types(self, store): 342 """See `IMailingList`.""" 343 results = store.query(ContentFilter).filter( 344 ContentFilter.mailing_list == self, 345 ContentFilter.filter_type == FilterType.filter_mime) 346 for content_filter in results: 347 yield content_filter.filter_pattern 348 349 @filter_types.setter 350 @dbconnection 351 def filter_types(self, store, sequence): 352 """See `IMailingList`.""" 353 # First, delete all existing MIME type filter patterns. 354 results = store.query(ContentFilter).filter( 355 ContentFilter.mailing_list == self, 356 ContentFilter.filter_type == FilterType.filter_mime) 357 results.delete() 358 # Now add all the new filter types. 359 for mime_type in sequence: 360 content_filter = ContentFilter( 361 self, mime_type, FilterType.filter_mime) 362 store.add(content_filter) 363 364 @property 365 @dbconnection 366 def pass_types(self, store): 367 """See `IMailingList`.""" 368 results = store.query(ContentFilter).filter( 369 ContentFilter.mailing_list == self, 370 ContentFilter.filter_type == FilterType.pass_mime) 371 for content_filter in results: 372 yield content_filter.filter_pattern 373 374 @pass_types.setter 375 @dbconnection 376 def pass_types(self, store, sequence): 377 """See `IMailingList`.""" 378 # First, delete all existing MIME type pass patterns. 379 results = store.query(ContentFilter).filter( 380 ContentFilter.mailing_list == self, 381 ContentFilter.filter_type == FilterType.pass_mime) 382 results.delete() 383 # Now add all the new filter types. 384 for mime_type in sequence: 385 content_filter = ContentFilter( 386 self, mime_type, FilterType.pass_mime) 387 store.add(content_filter) 388 389 @property 390 @dbconnection 391 def filter_extensions(self, store): 392 """See `IMailingList`.""" 393 results = store.query(ContentFilter).filter( 394 ContentFilter.mailing_list == self, 395 ContentFilter.filter_type == FilterType.filter_extension) 396 for content_filter in results: 397 yield content_filter.filter_pattern 398 399 @filter_extensions.setter 400 @dbconnection 401 def filter_extensions(self, store, sequence): 402 """See `IMailingList`.""" 403 # First, delete all existing file extensions filter patterns. 404 results = store.query(ContentFilter).filter( 405 ContentFilter.mailing_list == self, 406 ContentFilter.filter_type == FilterType.filter_extension) 407 results.delete() 408 # Now add all the new filter types. 409 for mime_type in sequence: 410 content_filter = ContentFilter( 411 self, mime_type, FilterType.filter_extension) 412 store.add(content_filter) 413 414 @property 415 @dbconnection 416 def pass_extensions(self, store): 417 """See `IMailingList`.""" 418 results = store.query(ContentFilter).filter( 419 ContentFilter.mailing_list == self, 420 ContentFilter.filter_type == FilterType.pass_extension) 421 for content_filter in results: 422 yield content_filter.filter_pattern 423 424 @pass_extensions.setter 425 @dbconnection 426 def pass_extensions(self, store, sequence): 427 """See `IMailingList`.""" 428 # First, delete all existing file extensions pass patterns. 429 results = store.query(ContentFilter).filter( 430 ContentFilter.mailing_list == self, 431 ContentFilter.filter_type == FilterType.pass_extension) 432 results.delete() 433 # Now add all the new filter types. 434 for mime_type in sequence: 435 content_filter = ContentFilter( 436 self, mime_type, FilterType.pass_extension) 437 store.add(content_filter) 438 439 def get_roster(self, role): 440 """See `IMailingList`.""" 441 if role is MemberRole.member: 442 return self.members 443 elif role is MemberRole.owner: 444 return self.owners 445 elif role is MemberRole.moderator: 446 return self.moderators 447 elif role is MemberRole.nonmember: 448 return self.nonmembers 449 else: 450 raise ValueError('Undefined MemberRole: {}'.format(role)) 451 452 def _get_subscriber(self, store, subscriber, role): 453 """Get some information about a user/address. 454 455 Returns a 2-tuple of (member, email) for the given subscriber. If the 456 subscriber is is not an ``IAddress`` or ``IUser``, then a 2-tuple of 457 (None, None) is returned. If the subscriber is not already 458 subscribed, then (None, email) is returned. If the subscriber is an 459 ``IUser`` and does not have a preferred address, (member, None) is 460 returned. 461 """ 462 member = None 463 email = None 464 if IAddress.providedBy(subscriber): 465 member = store.query(Member).filter( 466 Member.role == role, 467 Member.list_id == self._list_id, 468 Member._address == subscriber).first() 469 email = subscriber.email 470 elif IUser.providedBy(subscriber): 471 if subscriber.preferred_address is None: 472 raise MissingPreferredAddressError(subscriber) 473 email = subscriber.preferred_address.email 474 member = store.query(Member).filter( 475 Member.role == role, 476 Member.list_id == self._list_id, 477 Member._user == subscriber).first() 478 return member, email 479 480 @dbconnection 481 def is_subscribed(self, store, subscriber, role=MemberRole.member): 482 """See `IMailingList`.""" 483 member, email = self._get_subscriber(store, subscriber, role) 484 return member is not None 485 486 @dbconnection 487 def subscribe(self, store, subscriber, role=MemberRole.member, 488 send_welcome_message=None): 489 """See `IMailingList`.""" 490 member, email = self._get_subscriber(store, subscriber, role) 491 test_email = email or subscriber.lower() 492 # Allow list posting address only for nonmember role. 493 if (test_email == self.posting_address and 494 role != MemberRole.nonmember): 495 raise InvalidEmailAddressError('List posting address not allowed') 496 if member is not None: 497 raise AlreadySubscribedError(self.fqdn_listname, email, role) 498 if IBanManager(self).is_banned(test_email): 499 raise MembershipIsBannedError(self, test_email) 500 member = Member(role=role, 501 list_id=self._list_id, 502 subscriber=subscriber) 503 member.preferences = Preferences() 504 store.add(member) 505 notify(SubscriptionEvent( 506 self, member, send_welcome_message=send_welcome_message)) 507 return member 508 509 510@public 511@implementer(IAcceptableAlias) 512class AcceptableAlias(Model): 513 """See `IAcceptableAlias`.""" 514 515 __tablename__ = 'acceptablealias' 516 517 id = Column(Integer, primary_key=True) 518 519 mailing_list_id = Column( 520 Integer, ForeignKey('mailinglist.id'), 521 index=True, nullable=False) 522 mailing_list = relationship('MailingList', backref='acceptablealias') 523 alias = Column(SAUnicode, index=True, nullable=False) 524 525 def __init__(self, mailing_list, alias): 526 super().__init__() 527 self.mailing_list = mailing_list 528 self.alias = alias 529 530 531@public 532@implementer(IAcceptableAliasSet) 533class AcceptableAliasSet: 534 """See `IAcceptableAliasSet`.""" 535 536 def __init__(self, mailing_list): 537 self._mailing_list = mailing_list 538 539 @dbconnection 540 def clear(self, store): 541 """See `IAcceptableAliasSet`.""" 542 store.query(AcceptableAlias).filter( 543 AcceptableAlias.mailing_list == self._mailing_list).delete() 544 545 @dbconnection 546 def add(self, store, alias): 547 if not (alias.startswith('^') or '@' in alias): 548 raise ValueError(alias) 549 alias = AcceptableAlias(self._mailing_list, alias.lower()) 550 store.add(alias) 551 552 @dbconnection 553 def remove(self, store, alias): 554 store.query(AcceptableAlias).filter( 555 AcceptableAlias.mailing_list == self._mailing_list, 556 AcceptableAlias.alias == alias.lower()).delete() 557 558 @property 559 @dbconnection 560 def aliases(self, store): 561 aliases = store.query(AcceptableAlias).filter( 562 AcceptableAlias.mailing_list_id == self._mailing_list.id) 563 for alias in aliases: 564 yield alias.alias 565 566 567@public 568@implementer(IListArchiver) 569class ListArchiver(Model): 570 """See `IListArchiver`.""" 571 572 __tablename__ = 'listarchiver' 573 574 id = Column(Integer, primary_key=True) 575 576 mailing_list_id = Column( 577 Integer, ForeignKey('mailinglist.id'), 578 index=True, nullable=False) 579 mailing_list = relationship('MailingList') 580 581 name = Column(SAUnicode, nullable=False) 582 _is_enabled = Column(Boolean) 583 584 def __init__(self, mailing_list, archiver_name, system_archiver): 585 self.mailing_list = mailing_list 586 self.name = archiver_name 587 self._is_enabled = system_archiver.is_enabled 588 589 @property 590 def system_archiver(self): 591 for archiver in config.archivers: # pragma: no branch 592 if archiver.name == self.name: 593 return archiver 594 raise AssertionError('Archiver not found: {}'.format(self.name)) 595 596 @property 597 def is_enabled(self): 598 return self.system_archiver.is_enabled and self._is_enabled 599 600 @is_enabled.setter 601 def is_enabled(self, value): 602 self._is_enabled = value 603 604 605@public 606@implementer(IListArchiverSet) 607class ListArchiverSet: 608 @dbconnection 609 def __init__(self, store, mailing_list): 610 self._mailing_list = mailing_list 611 system_archivers = {} 612 for archiver in config.archivers: 613 system_archivers[archiver.name] = archiver 614 # Add any system enabled archivers which aren't already associated 615 # with the mailing list. 616 for archiver_name in system_archivers: 617 exists = store.query(ListArchiver).filter( 618 ListArchiver.mailing_list == mailing_list, 619 ListArchiver.name == archiver_name).one_or_none() 620 if exists is None: 621 store.add(ListArchiver(mailing_list, archiver_name, 622 system_archivers[archiver_name])) 623 624 @property 625 @dbconnection 626 def archivers(self, store): 627 entries = store.query(ListArchiver).filter( 628 ListArchiver.mailing_list == self._mailing_list) 629 yield from entries 630 631 @dbconnection 632 def get(self, store, archiver_name): 633 return store.query(ListArchiver).filter( 634 ListArchiver.mailing_list == self._mailing_list, 635 ListArchiver.name == archiver_name).one_or_none() 636 637 638@public 639@implementer(IHeaderMatch) 640class HeaderMatch(Model): 641 """See `IHeaderMatch`.""" 642 643 __tablename__ = 'headermatch' 644 645 id = Column(Integer, primary_key=True) 646 647 mailing_list_id = Column( 648 Integer, 649 ForeignKey('mailinglist.id'), 650 index=True, nullable=False) 651 652 _position = Column('position', Integer, index=True, default=0) 653 header = Column(SAUnicode) 654 pattern = Column(SAUnicode) 655 chain = Column(SAUnicode, nullable=True) 656 tag = Column(SAUnicode, nullable=True) 657 658 def __init__(self, **kw): 659 position = kw.pop('position', None) 660 if position is not None: 661 kw['_position'] = position 662 super().__init__(**kw) 663 664 @hybrid_property 665 def position(self): 666 """See `IHeaderMatch`.""" 667 return self._position 668 669 @position.setter 670 @dbconnection 671 def position(self, store, value): 672 """See `IHeaderMatch`.""" 673 if value < 0: 674 raise ValueError('Negative indexes are not supported') 675 if value == self.position: 676 # Nothing to do. 677 return 678 existing_count = store.query(HeaderMatch).filter( 679 HeaderMatch.mailing_list == self.mailing_list).count() 680 if value >= existing_count: 681 raise ValueError( 682 'There are {count} header matches for this list, ' 683 'the new position cannot be {count} or higher'.format( 684 count=existing_count)) 685 if value < self.position: 686 # Moving up: header matches between the new position and the 687 # current one must be moved down the list to make room. Those 688 # after the current position must not be changed. 689 for header_match in store.query(HeaderMatch).filter( 690 HeaderMatch.mailing_list == self.mailing_list, 691 HeaderMatch.position >= value, 692 HeaderMatch.position < self.position): 693 header_match._position = header_match.position + 1 694 elif value > self.position: 695 # Moving down: header matches between the current position and the 696 # new one must be moved up the list to make room. Those after the 697 # new position must not be changed. 698 for header_match in store.query(HeaderMatch).filter( 699 HeaderMatch.mailing_list == self.mailing_list, 700 HeaderMatch.position > self.position, 701 HeaderMatch.position <= value): 702 header_match._position = header_match.position - 1 703 self._position = value 704 705 706@public 707@implementer(IHeaderMatchList) 708class HeaderMatchList: 709 """See `IHeaderMatchList`.""" 710 711 # All write operations must mark the mailing list's header_matches 712 # collection as expired: 713 # https://docs.sqlalchemy.org/en/latest/orm/session_state_management.html#refreshing-expiring 714 715 def __init__(self, mailing_list): 716 self._mailing_list = mailing_list 717 718 @dbconnection 719 def clear(self, store): 720 """See `IHeaderMatchList`.""" 721 # https://docs.sqlalchemy.org/en/latest/orm/session_basics.html 722 del self._mailing_list.header_matches[:] 723 724 @dbconnection 725 def append(self, store, header, pattern, chain=None, tag=None): 726 header = header.lower() 727 existing = store.query(HeaderMatch).filter( 728 HeaderMatch.mailing_list == self._mailing_list, 729 HeaderMatch.header == header, 730 HeaderMatch.pattern == pattern).count() 731 if existing > 0: 732 raise ValueError('Pattern already exists') 733 last_position = store.query(HeaderMatch.position).filter( 734 HeaderMatch.mailing_list == self._mailing_list 735 ).order_by(HeaderMatch.position.desc()).limit(1).scalar() 736 if last_position is None: 737 last_position = -1 738 header_match = HeaderMatch( 739 mailing_list=self._mailing_list, 740 header=header, pattern=pattern, chain=chain, 741 position=last_position + 1, tag=tag) 742 store.add(header_match) 743 store.expire(self._mailing_list, ['header_matches']) 744 745 @dbconnection 746 def insert(self, store, index, header, pattern, chain=None, tag=None): 747 self.append(header, pattern, chain, tag) 748 # Get the header match that was just added. 749 header_match = store.query(HeaderMatch).filter( 750 HeaderMatch.mailing_list == self._mailing_list, 751 HeaderMatch.header == header.lower(), 752 HeaderMatch.pattern == pattern, 753 HeaderMatch.chain == chain, 754 HeaderMatch.tag == tag).one() 755 header_match.position = index 756 store.expire(self._mailing_list, ['header_matches']) 757 758 @dbconnection 759 def remove(self, store, header, pattern): 760 header = header.lower() 761 # Query.delete() has many caveats, don't use it here: 762 # https://docs.sqlalchemy.org/en/latest/orm/query.html#sqlalchemy.orm.query.Query.delete 763 try: 764 existing = store.query(HeaderMatch).filter( 765 HeaderMatch.mailing_list == self._mailing_list, 766 HeaderMatch.header == header, 767 HeaderMatch.pattern == pattern).one() 768 except NoResultFound: 769 raise ValueError('Pattern does not exist') 770 else: 771 store.delete(existing) 772 self._restore_position_sequence() 773 store.expire(self._mailing_list, ['header_matches']) 774 775 @dbconnection 776 def __getitem__(self, store, index): 777 if index < 0: 778 index = len(self) + index 779 try: 780 return store.query(HeaderMatch).filter( 781 HeaderMatch.mailing_list == self._mailing_list, 782 HeaderMatch.position == index).one() 783 except NoResultFound: 784 raise IndexError 785 786 @dbconnection 787 def __delitem__(self, store, index): 788 try: 789 existing = store.query(HeaderMatch).filter( 790 HeaderMatch.mailing_list == self._mailing_list, 791 HeaderMatch.position == index).one() 792 except NoResultFound: 793 raise IndexError 794 else: 795 store.delete(existing) 796 self._restore_position_sequence() 797 store.expire(self._mailing_list, ['header_matches']) 798 799 @dbconnection 800 def __len__(self, store): 801 return store.query(HeaderMatch).filter( 802 HeaderMatch.mailing_list == self._mailing_list).count() 803 804 @dbconnection 805 def __iter__(self, store): 806 yield from store.query(HeaderMatch).filter( 807 HeaderMatch.mailing_list == self._mailing_list 808 ).order_by(HeaderMatch.position) 809 810 @dbconnection 811 def _restore_position_sequence(self, store): 812 # Restore a continuous position sequence for this mailing list's 813 # header matches. 814 # 815 # The header match positions may not be continuous after deleting an 816 # item. It won't prevent this component from working properly, but 817 # it's cleaner to restore a continuous sequence. 818 for position, match in enumerate(store.query(HeaderMatch).filter( 819 HeaderMatch.mailing_list == self._mailing_list 820 ).order_by(HeaderMatch.position)): 821 match._position = position 822 store.expire(self._mailing_list, ['header_matches']) 823 824 @dbconnection 825 def get_by_tag(self, store, tag): 826 # Get all the header matches that correspond to a single tag. 827 entries = store.query(HeaderMatch).filter( 828 HeaderMatch.mailing_list == self._mailing_list, 829 HeaderMatch.tag == tag 830 ).order_by(HeaderMatch.position) 831 yield from entries 832 833 @dbconnection 834 def filter(self, store, header=None, chain=None, tag=None): 835 entries = store.query(HeaderMatch).filter( 836 HeaderMatch.mailing_list == self._mailing_list) 837 if tag: 838 entries = entries.filter(HeaderMatch.tag == tag) 839 if header: 840 entries = entries.filter(HeaderMatch.header == header) 841 if chain: 842 entries = entries.filter(HeaderMatch.chain == chain) 843 yield from entries.order_by(HeaderMatch.position) 844