1# Copyright (C) 2007-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"""Implementations of the pending requests interfaces."""
19
20from datetime import timedelta
21from mailman.database.model import Model
22from mailman.database.transaction import dbconnection
23from mailman.database.types import Enum, SAUnicode
24from mailman.interfaces.pending import IPendable, IPendings
25from mailman.interfaces.requests import IListRequests, RequestType
26from mailman.model.pending import Pended, PendedKeyValue
27from mailman.utilities.queries import QuerySequence
28from pickle import dumps, loads
29from public import public
30from sqlalchemy import Column, ForeignKey, Integer
31from sqlalchemy.orm import relationship
32from zope.component import getUtility
33from zope.interface import implementer
34
35
36@public
37@implementer(IPendable)
38class DataPendable(dict):
39    """See `IPendable`."""
40
41    PEND_TYPE = 'data'
42
43    def update(self, mapping):
44        # Keys and values must be strings (unicodes, but bytes values are
45        # accepted for now).  Any other types for keys are a programming
46        # error.  If we find a non-SAUnicode value, pickle it and encode it in
47        # such a way that it will be properly reconstituted when unpended.
48        clean_mapping = {}
49        for key, value in mapping.items():
50            assert isinstance(key, (bytes, str))
51            if not isinstance(value, str):
52                key = '_pck_' + key
53                value = dumps(value).decode('raw-unicode-escape')
54            clean_mapping[key] = value
55        super().update(clean_mapping)
56
57
58@public
59@implementer(IListRequests)
60class ListRequests:
61    """See `IListRequests`."""
62
63    def __init__(self, mailing_list):
64        self.mailing_list = mailing_list
65
66    @property
67    @dbconnection
68    def count(self, store):
69        return store.query(_Request).filter_by(
70            mailing_list=self.mailing_list).count()
71
72    @dbconnection
73    def count_of(self, store, request_type):
74        return store.query(_Request).filter_by(
75            mailing_list=self.mailing_list, request_type=request_type).count()
76
77    @property
78    @dbconnection
79    def held_requests(self, store):
80        results = store.query(_Request).filter_by(
81            mailing_list=self.mailing_list)
82        yield from results
83
84    @dbconnection
85    def of_type(self, store, request_type):
86        return QuerySequence(
87            store.query(_Request).filter_by(
88                mailing_list=self.mailing_list, request_type=request_type
89                ).order_by(_Request.id))
90
91    @dbconnection
92    def hold_request(self, store, request_type, key, data=None):
93        # Check to make sure `request_type` is a valid Enum.
94        if not isinstance(request_type, RequestType):
95            try:
96                request_type = RequestType(request_type)
97            except ValueError:
98                raise TypeError(request_type)
99
100        if data is None:
101            data_hash = None
102        else:
103            pendable = DataPendable()
104            pendable.update(data)
105            token = getUtility(IPendings).add(pendable, timedelta(days=5000))
106            data_hash = token
107        request = _Request(key, request_type, self.mailing_list, data_hash)
108        store.add(request)
109        # XXX The caller needs a valid id immediately, so flush the changes
110        # now to the SA transaction context.  Otherwise .id would not be
111        # valid.  Hopefully this has no unintended side-effects.
112        store.flush()
113        return request.id
114
115    @dbconnection
116    def get_request(self, store, request_id, request_type=None):
117        result = store.query(_Request).get(request_id)
118        if result is None or result.mailing_list != self.mailing_list:
119            return None
120        if request_type is not None and result.request_type != request_type:
121            return None
122        if result.data_hash is None:
123            return result.key, None
124        pendable = getUtility(IPendings).confirm(
125            result.data_hash, expunge=False)
126        if pendable is None:
127            return None
128        data = dict()
129        # Unpickle any non-SAUnicode values.
130        for key, value in pendable.items():
131            if key.startswith('_pck_'):
132                data[key[5:]] = loads(value.encode('raw-unicode-escape'))
133            else:
134                data[key] = value
135        # Some APIs need the request type.
136        data['_request_type'] = result.request_type.name
137        return result.key, data
138
139    @dbconnection
140    def delete_request(self, store, request_id):
141        request = store.query(_Request).get(request_id)
142        if request is None:
143            raise KeyError(request_id)
144        # Throw away the pended data.
145        getUtility(IPendings).confirm(request.data_hash)
146        store.delete(request)
147
148    @dbconnection
149    def clear(self, store):
150        for token, pendable in getUtility(IPendings).find(
151                mlist=self.mailing_list,
152                confirm=False):
153            pended = store.query(Pended).filter_by(token=token).first()
154            store.query(PendedKeyValue).filter_by(pended_id=pended.id).delete()
155            store.delete(pended)
156
157
158class _Request(Model):
159    """Table for mailing list hold requests."""
160
161    __tablename__ = '_request'
162
163    id = Column(Integer, primary_key=True)
164    key = Column(SAUnicode)
165    request_type = Column(Enum(RequestType))
166    data_hash = Column(SAUnicode)
167
168    mailing_list_id = Column(Integer, ForeignKey('mailinglist.id'), index=True)
169    mailing_list = relationship('MailingList')
170
171    def __init__(self, key, request_type, mailing_list, data_hash):
172        super().__init__()
173        self.key = key
174        self.request_type = request_type
175        self.mailing_list = mailing_list
176        self.data_hash = data_hash
177