Add configuration for dsn lifetime in message store.
[mailman.git] / src / mailman / model / member.py
blob42110f1f2f4e7e0caa9e51b7193d05c0d0929263
1 # Copyright (C) 2007-2023 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 <https://www.gnu.org/licenses/>.
18 """Model for members."""
20 from datetime import datetime
21 from mailman.core.constants import system_preferences
22 from mailman.database.model import Model
23 from mailman.database.transaction import dbconnection
24 from mailman.database.types import Enum, SAUnicode, UUID
25 from mailman.interfaces.action import Action
26 from mailman.interfaces.address import IAddress
27 from mailman.interfaces.listmanager import IListManager
28 from mailman.interfaces.member import (
29 DeliveryStatus,
30 IMember,
31 IMembershipManager,
32 MemberRole,
33 MembershipError,
34 SubscriptionMode,
35 UnsubscriptionEvent,
37 from mailman.interfaces.user import IUser, UnverifiedAddressError
38 from mailman.interfaces.usermanager import IUserManager
39 from mailman.utilities.datetime import now
40 from mailman.utilities.uid import UIDFactory
41 from public import public
42 from sqlalchemy import and_, Column, DateTime, ForeignKey, Integer
43 from sqlalchemy.orm import relationship
44 from zope.component import getUtility
45 from zope.event import notify
46 from zope.interface import implementer
49 uid_factory = UIDFactory(context='members')
52 @public
53 @implementer(IMember)
54 class Member(Model):
55 """See `IMember`."""
57 __tablename__ = 'member'
59 id = Column(Integer, primary_key=True)
60 _member_id = Column(UUID)
61 role = Column(Enum(MemberRole), index=True)
62 list_id = Column(SAUnicode, index=True)
64 moderation_action = Column(Enum(Action))
66 address_id = Column(Integer, ForeignKey('address.id'), index=True)
67 _address = relationship('Address')
68 preferences_id = Column(Integer, ForeignKey('preferences.id'), index=True)
69 preferences = relationship('Preferences')
70 user_id = Column(Integer, ForeignKey('user.id'), index=True)
71 _user = relationship('User')
73 bounce_score = Column(Integer, default=0, index=True)
74 last_bounce_received = Column(DateTime)
75 last_warning_sent = Column(DateTime, default=datetime.min, index=True)
76 total_warnings_sent = Column(Integer, default=0, index=True)
78 _mailing_list = None
80 def __init__(self, role, list_id, subscriber):
81 self._member_id = uid_factory.new()
82 self.role = role
83 self.list_id = list_id
84 if IAddress.providedBy(subscriber):
85 self._address = subscriber
86 # Look this up dynamically.
87 self._user = None
88 elif IUser.providedBy(subscriber):
89 self._user = subscriber
90 # Look this up dynamically.
91 self._address = None
92 else:
93 raise ValueError('subscriber must be a user or address')
94 if role in (MemberRole.owner, MemberRole.moderator):
95 self.moderation_action = Action.accept
96 else:
97 assert role in (MemberRole.member, MemberRole.nonmember), (
98 'Invalid MemberRole: {}'.format(role))
99 self.moderation_action = None
101 def __repr__(self):
102 return '<Member: {} on {} as {}>'.format(
103 self.address, self.mailing_list.fqdn_listname, self.role)
105 @property
106 def mailing_list(self):
107 """See `IMember`."""
108 if self._mailing_list is None:
109 self._mailing_list = getUtility(
110 IListManager).get_by_list_id(self.list_id)
111 return self._mailing_list
113 @property
114 def member_id(self):
115 """See `IMember`."""
116 return self._member_id
118 @property
119 def address(self):
120 """See `IMember`."""
121 return (self._user.preferred_address
122 if self._address is None
123 else self._address)
125 @property
126 def subscription_mode(self):
127 """See `IMember`"""
128 return (SubscriptionMode.as_address
129 if self._address
130 else SubscriptionMode.as_user)
132 @address.setter
133 def address(self, new_address):
134 """See `IMember`."""
135 if self._address is None:
136 # XXX Either we need a better exception here, or we should allow
137 # changing a subscription from preferred address to explicit
138 # address (and vice versa via del'ing the .address attribute.
139 raise MembershipError('Membership is via preferred address')
140 if new_address.verified_on is None:
141 # A member cannot change their subscription address to an
142 # unverified address.
143 raise UnverifiedAddressError('Unverified address')
144 user = getUtility(IUserManager).get_user(new_address.email)
145 if user is None or user != self.user:
146 raise MembershipError('Address is not controlled by user')
147 self._address = new_address
149 @property
150 def user(self):
151 """See `IMember`."""
152 return (self._user
153 if self._address is None
154 else self._address.user)
156 @property
157 def subscriber(self):
158 return (self._user if self._address is None else self._address)
160 @property
161 def display_name(self):
162 # Try to find a non-empty display name. We first look at the directly
163 # subscribed record, which will either be the address or the user.
164 # That's handled automatically by going through member.subscriber. If
165 # that doesn't give us something useful, try whatever user is linked
166 # to the subscriber.
167 if self.subscriber.display_name:
168 return self.subscriber.display_name
169 # If an unlinked address is subscribed there will be no .user.
170 elif self.user is not None and self.user.display_name:
171 return self.user.display_name
172 else:
173 return ''
175 def _lookup(self, preference, default=None):
176 pref = getattr(self.preferences, preference)
177 if pref is not None:
178 return pref
179 pref = getattr(self.address.preferences, preference)
180 if pref is not None:
181 return pref
182 if self.address.user:
183 pref = getattr(self.address.user.preferences, preference)
184 if pref is not None:
185 return pref
186 if default is None:
187 return getattr(system_preferences, preference)
188 return default
190 @property
191 def acknowledge_posts(self):
192 """See `IMember`."""
193 return self._lookup('acknowledge_posts')
195 @property
196 def preferred_language(self):
197 """See `IMember`."""
198 missing = object()
199 language = self._lookup('preferred_language', missing)
200 return (self.mailing_list.preferred_language
201 if language is missing
202 else language)
204 @property
205 def receive_list_copy(self):
206 """See `IMember`."""
207 return self._lookup('receive_list_copy')
209 @property
210 def receive_own_postings(self):
211 """See `IMember`."""
212 return self._lookup('receive_own_postings')
214 @property
215 def delivery_mode(self):
216 """See `IMember`."""
217 return self._lookup('delivery_mode')
219 @property
220 def delivery_status(self):
221 """See `IMember`."""
222 return self._lookup('delivery_status')
224 @dbconnection
225 def unsubscribe(self, store):
226 """See `IMember`."""
227 # Yes, this must get triggered before self is deleted.
228 notify(UnsubscriptionEvent(self.mailing_list, self))
229 store.delete(self.preferences)
230 store.delete(self)
233 @public
234 @implementer(IMembershipManager)
235 class MembershipManager:
236 """See `IMembershipManager`."""
238 @dbconnection
239 def memberships_pending_warning(self, store):
240 """See `IMembershipManager`."""
241 from mailman.model.mailinglist import MailingList
242 from mailman.model.preferences import Preferences
244 # maxking: We don't care so much about the bounce score here since it
245 # could have been reset due to bounce info getting stale. We will send
246 # warnings to people who have been disabled already, regardless of
247 # their bounce score. Same is true below for removal.
248 query = store.query(
249 Member,
250 MailingList.bounce_you_are_disabled_warnings_interval).join(
251 MailingList, Member.list_id == MailingList._list_id).join(
252 Member.preferences).filter(and_(
253 Member.role == MemberRole.member,
254 MailingList.process_bounces == True, # noqa: E712
255 Member.total_warnings_sent < MailingList.bounce_you_are_disabled_warnings, # noqa: E501
256 Preferences.delivery_status == DeliveryStatus.by_bounces))
258 # XXX(maxking): This is IMO a query that *should* work, but I haven't
259 # been able to get it to work in my tests. It could be due to lack of
260 # native datetime type in SQLite, which we use for tests. Hence, we are
261 # going to do some filtering in Python, which is inefficient, but
262 # works. We do filter as much as possible in SQL, so hopefully the
263 # output of the above query won't be *too* many.
264 # TODO: Port the Python loop below to SQLAlchemy.
266 # (func.DATETIME(Member.last_warning_sent) +
267 # func.DATETIME(MailingList.bounce_you_are_disabled_warnings_interval))
268 # < func.DATETIME(now())))
270 for member, interval in query.all():
271 if (member.last_warning_sent + interval) <= now():
272 yield member
274 @dbconnection
275 def memberships_pending_removal(self, store):
276 """See `IMembershipManager`."""
277 from mailman.model.mailinglist import MailingList
278 from mailman.model.preferences import Preferences
280 query = store.query(
281 Member,
282 MailingList.bounce_you_are_disabled_warnings_interval,
283 MailingList.bounce_you_are_disabled_warnings).join(
284 MailingList, Member.list_id == MailingList._list_id).join(
285 Member.preferences).filter(and_(
286 Member.role == MemberRole.member,
287 MailingList.process_bounces == True, # noqa: E712
288 Member.total_warnings_sent >= MailingList.bounce_you_are_disabled_warnings, # noqa: E501
289 Preferences.delivery_status == DeliveryStatus.by_bounces))
291 for member, interval, warnings in query.all():
292 if (member.last_warning_sent + interval) <= now() or warnings == 0:
293 yield member