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)
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 <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 (
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')
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)
80 def __init__(self
, role
, list_id
, subscriber
):
81 self
._member
_id
= uid_factory
.new()
83 self
.list_id
= list_id
84 if IAddress
.providedBy(subscriber
):
85 self
._address
= subscriber
86 # Look this up dynamically.
88 elif IUser
.providedBy(subscriber
):
89 self
._user
= subscriber
90 # Look this up dynamically.
93 raise ValueError('subscriber must be a user or address')
94 if role
in (MemberRole
.owner
, MemberRole
.moderator
):
95 self
.moderation_action
= Action
.accept
97 assert role
in (MemberRole
.member
, MemberRole
.nonmember
), (
98 'Invalid MemberRole: {}'.format(role
))
99 self
.moderation_action
= None
102 return '<Member: {} on {} as {}>'.format(
103 self
.address
, self
.mailing_list
.fqdn_listname
, self
.role
)
106 def mailing_list(self
):
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
116 return self
._member
_id
121 return (self
._user
.preferred_address
122 if self
._address
is None
126 def subscription_mode(self
):
128 return (SubscriptionMode
.as_address
130 else SubscriptionMode
.as_user
)
133 def address(self
, new_address
):
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
153 if self
._address
is None
154 else self
._address
.user
)
157 def subscriber(self
):
158 return (self
._user
if self
._address
is None else self
._address
)
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
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
175 def _lookup(self
, preference
, default
=None):
176 pref
= getattr(self
.preferences
, preference
)
179 pref
= getattr(self
.address
.preferences
, preference
)
182 if self
.address
.user
:
183 pref
= getattr(self
.address
.user
.preferences
, preference
)
187 return getattr(system_preferences
, preference
)
191 def acknowledge_posts(self
):
193 return self
._lookup
('acknowledge_posts')
196 def preferred_language(self
):
199 language
= self
._lookup
('preferred_language', missing
)
200 return (self
.mailing_list
.preferred_language
201 if language
is missing
205 def receive_list_copy(self
):
207 return self
._lookup
('receive_list_copy')
210 def receive_own_postings(self
):
212 return self
._lookup
('receive_own_postings')
215 def delivery_mode(self
):
217 return self
._lookup
('delivery_mode')
220 def delivery_status(self
):
222 return self
._lookup
('delivery_status')
225 def unsubscribe(self
, store
):
227 # Yes, this must get triggered before self is deleted.
228 notify(UnsubscriptionEvent(self
.mailing_list
, self
))
229 store
.delete(self
.preferences
)
234 @implementer(IMembershipManager
)
235 class MembershipManager
:
236 """See `IMembershipManager`."""
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.
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():
275 def memberships_pending_removal(self
, store
):
276 """See `IMembershipManager`."""
277 from mailman
.model
.mailinglist
import MailingList
278 from mailman
.model
.preferences
import Preferences
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: