1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
|
# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
__metaclass__ = type
__all__ = [
'HasRenewalPolicyMixin',
'ProposedTeamMembersEditView',
'TeamAddView',
'TeamBadges',
'TeamBrandingView',
'TeamContactAddressView',
'TeamEditView',
'TeamHierarchyView',
'TeamMailingListConfigurationView',
'TeamMailingListModerationView',
'TeamMailingListSubscribersView',
'TeamMapData',
'TeamMapLtdData',
'TeamMapView',
'TeamMapLtdView',
'TeamMemberAddView',
'TeamPrivacyAdapter',
]
from datetime import datetime
import math
from urllib import unquote
import pytz
from zope.app.form.browser import TextAreaWidget
from zope.component import getUtility
from zope.formlib import form
from zope.interface import (
implements,
Interface,
)
from zope.schema import Choice
from zope.schema.vocabulary import (
SimpleTerm,
SimpleVocabulary,
)
from canonical.launchpad import _
from canonical.launchpad.interfaces.authtoken import LoginTokenType
from canonical.launchpad.interfaces.emailaddress import IEmailAddressSet
from canonical.launchpad.interfaces.logintoken import ILoginTokenSet
from canonical.launchpad.interfaces.validation import validate_new_team_email
from canonical.launchpad.webapp import (
canonical_url,
LaunchpadView,
)
from canonical.launchpad.webapp.authorization import check_permission
from canonical.launchpad.webapp.badge import HasBadgeBase
from canonical.launchpad.webapp.batching import BatchNavigator
from canonical.launchpad.webapp.interfaces import ILaunchBag
from canonical.launchpad.webapp.menu import structured
from canonical.lazr.interfaces import IObjectPrivacy
from lp.app.browser.launchpadform import (
action,
custom_widget,
LaunchpadEditFormView,
LaunchpadFormView,
)
from lp.app.browser.tales import PersonFormatterAPI
from lp.app.errors import UnexpectedFormData
from lp.app.validators import LaunchpadValidationError
from lp.app.widgets.itemswidgets import (
LaunchpadRadioWidget,
LaunchpadRadioWidgetWithDescription,
)
from lp.app.widgets.owner import HiddenUserWidget
from lp.app.widgets.popup import PersonPickerWidget
from lp.registry.browser.branding import BrandingChangeView
from lp.registry.interfaces.mailinglist import (
IMailingList,
IMailingListSet,
MailingListStatus,
PostedMessageStatus,
PURGE_STATES,
)
from lp.registry.interfaces.person import (
ImmutableVisibilityError,
IPersonSet,
ITeam,
ITeamContactAddressForm,
ITeamCreation,
PersonVisibility,
PRIVATE_TEAM_PREFIX,
TeamContactMethod,
TeamSubscriptionPolicy,
)
from lp.registry.interfaces.teammembership import (
CyclicalTeamMembershipError,
TeamMembershipStatus,
)
from lp.services.fields import PublicPersonChoice
from lp.services.propertycache import cachedproperty
class TeamPrivacyAdapter:
"""Provides `IObjectPrivacy` for `ITeam`."""
implements(IObjectPrivacy)
def __init__(self, context):
self.context = context
@property
def is_private(self):
"""Return True if the team is private, otherwise False."""
return self.context.visibility != PersonVisibility.PUBLIC
class TeamBadges(HasBadgeBase):
"""Provides `IHasBadges` for `ITeam`."""
def getPrivateBadgeTitle(self):
"""Return private badge info useful for a tooltip."""
return "This is a %s team" % self.context.visibility.title.lower()
class HasRenewalPolicyMixin:
"""Mixin to be used on forms which contain ITeam.renewal_policy.
This mixin will short-circuit Launchpad*FormView when defining whether
the renewal_policy widget should be displayed in a single or multi-line
layout. We need that because that field has a very long title, thus
breaking the page layout.
Since this mixin short-circuits Launchpad*FormView in some cases, it must
always precede Launchpad*FormView in the inheritance list.
"""
def isMultiLineLayout(self, field_name):
if field_name == 'renewal_policy':
return True
return super(HasRenewalPolicyMixin, self).isMultiLineLayout(
field_name)
def isSingleLineLayout(self, field_name):
if field_name == 'renewal_policy':
return False
return super(HasRenewalPolicyMixin, self).isSingleLineLayout(
field_name)
class TeamFormMixin:
"""Form to be used on forms which conditionally display team visibility.
The visibility field should only be shown to users with
launchpad.Commercial permission on the team.
"""
field_names = [
"name", "visibility", "displayname", "contactemail",
"teamdescription", "subscriptionpolicy",
"defaultmembershipperiod", "renewal_policy",
"defaultrenewalperiod", "teamowner",
]
private_prefix = PRIVATE_TEAM_PREFIX
def _validateVisibilityConsistency(self, value):
"""Perform a consistency check regarding visibility.
This property must be overridden if the current context is not an
IPerson.
"""
return self.context.visibilityConsistencyWarning(value)
@property
def _visibility(self):
"""Return the visibility for the object."""
return self.context.visibility
@property
def _name(self):
return self.context.name
def validate(self, data):
visibility = data.get('visibility', self._visibility)
if visibility != PersonVisibility.PUBLIC:
if visibility != self._visibility:
# If the user is attempting to change the team visibility
# ensure that there are no constraints being violated.
warning = self._validateVisibilityConsistency(visibility)
if warning is not None:
self.setFieldError('visibility', warning)
if (data['subscriptionpolicy']
!= TeamSubscriptionPolicy.RESTRICTED):
self.setFieldError(
'subscriptionpolicy',
'Private teams must have a Restricted subscription '
'policy.')
def conditionallyOmitVisibility(self):
"""Remove the visibility field if not authorized."""
if not check_permission('launchpad.Commercial', self.context):
self.form_fields = self.form_fields.omit('visibility')
class TeamEditView(TeamFormMixin, HasRenewalPolicyMixin,
LaunchpadEditFormView):
"""View for editing team details."""
schema = ITeam
@property
def label(self):
"""The form label."""
return 'Edit "%s" team' % self.context.displayname
page_title = label
custom_widget(
'renewal_policy', LaunchpadRadioWidget, orientation='vertical')
custom_widget(
'subscriptionpolicy', LaunchpadRadioWidgetWithDescription,
orientation='vertical')
custom_widget('teamdescription', TextAreaWidget, height=10, width=30)
def setUpFields(self):
"""See `LaunchpadViewForm`.
When editing a team the contactemail field is not displayed.
"""
# Make an instance copy of field_names so as to not modify the single
# class list.
self.field_names = list(self.field_names)
self.field_names.remove('contactemail')
self.field_names.remove('teamowner')
super(TeamEditView, self).setUpFields()
self.conditionallyOmitVisibility()
@action('Save', name='save')
def action_save(self, action, data):
try:
self.updateContextFromData(data)
except ImmutableVisibilityError, error:
self.request.response.addErrorNotification(str(error))
# Abort must be called or changes to fields before the one causing
# the error will be committed. If we have a database validation
# error we want to abort the transaction.
# XXX: BradCrittenden 2009-04-13 bug=360540: Remove the call to
# abort if it is moved up to updateContextFromData.
self._abort()
@property
def next_url(self):
return canonical_url(self.context)
cancel_url = next_url
def setUpWidgets(self):
"""See `LaunchpadViewForm`.
When a team has a mailing list, a PPA or is private, renames
are prohibited.
"""
mailing_list = getUtility(IMailingListSet).get(self.context.name)
has_mailing_list = (
mailing_list is not None and
mailing_list.status != MailingListStatus.PURGED)
is_private = self.context.visibility == PersonVisibility.PRIVATE
has_ppa = self.context.archive is not None
block_renaming = (has_mailing_list or is_private or has_ppa)
if block_renaming:
# This makes the field's widget display (i.e. read) only.
self.form_fields['name'].for_display = True
super(TeamEditView, self).setUpWidgets()
# Tweak the field form-help including an explanation for the
# read-only mode if necessary.
if block_renaming:
# Group the read-only mode reasons in textual form.
# Private teams can't be associated with mailing lists
# or PPAs yet, so it's a dominant condition.
if is_private:
reason = 'is private'
else:
if not has_mailing_list:
reason = 'has a PPA'
elif not has_ppa:
reason = 'has a mailing list'
else:
reason = 'has a mailing list and a PPA'
self.widgets['name'].hint = _(
'This team cannot be renamed because it %s.' % reason)
def generateTokenAndValidationEmail(email, team):
"""Send a validation message to the given email."""
login = getUtility(ILaunchBag).login
token = getUtility(ILoginTokenSet).new(
team, login, email, LoginTokenType.VALIDATETEAMEMAIL)
user = getUtility(ILaunchBag).user
token.sendTeamEmailAddressValidationEmail(user)
class MailingListTeamBaseView(LaunchpadFormView):
"""A base view for manipulating a team's mailing list.
This class contains common functionality for retrieving and
checking the state of mailing lists.
"""
def _getList(self):
"""Try to find a mailing list for this team.
:return: The mailing list object, or None if this team has no
mailing list.
"""
return getUtility(IMailingListSet).get(self.context.name)
def getListInState(self, *statuses):
"""Return this team's mailing list if it's in one of the given states.
:param statuses: The states that the mailing list must be in for it to
be returned.
:return: This team's IMailingList or None if the team doesn't have
a mailing list, or if it isn't in one of the given states.
"""
mailing_list = self._getList()
if mailing_list is not None and mailing_list.status in statuses:
return mailing_list
return None
@property
def list_is_usable(self):
"""Checks whether or not the list is usable; ie. accepting messages.
The list must exist and must be in a state acceptable to
MailingList.is_usable.
"""
mailing_list = self._getList()
return mailing_list is not None and mailing_list.is_usable
@property
def mailinglist_address(self):
"""The address for this team's mailing list."""
mailing_list = self._getList()
assert mailing_list is not None, (
'Attempt to find address of nonexistent mailing list.')
return mailing_list.address
class TeamContactAddressView(MailingListTeamBaseView):
"""A view for manipulating the team's contact address."""
schema = ITeamContactAddressForm
custom_widget(
'contact_method', LaunchpadRadioWidget, orientation='vertical')
@property
def label(self):
return "%s contact address" % self.context.displayname
page_title = label
def setUpFields(self):
"""See `LaunchpadFormView`.
"""
super(TeamContactAddressView, self).setUpFields()
# Replace the default contact_method field by a custom one.
self.form_fields = (
form.FormFields(self.getContactMethodField())
+ self.form_fields.omit('contact_method'))
def getContactMethodField(self):
"""Create the form.Fields to use for the contact_method field.
If the team has a mailing list that can be the team contact
method, the full range of TeamContactMethod terms shows up
in the contact_method vocabulary. Otherwise, the HOSTED_LIST
term does not show up in the vocabulary.
"""
terms = [term for term in TeamContactMethod]
for i, term in enumerate(TeamContactMethod):
if term.value == TeamContactMethod.HOSTED_LIST:
hosted_list_term_index = i
break
if self.list_is_usable:
# The team's mailing list can be used as the contact
# address. However we need to change the title of the
# corresponding term to include the list's email address.
title = structured(
'The Launchpad mailing list for this team - '
'<strong>%s</strong>', self.mailinglist_address)
hosted_list_term = SimpleTerm(
TeamContactMethod.HOSTED_LIST,
TeamContactMethod.HOSTED_LIST.name, title)
terms[hosted_list_term_index] = hosted_list_term
else:
# The team's mailing list does not exist or can't be
# used as the contact address. Remove the term from the
# field.
del terms[hosted_list_term_index]
return form.FormField(
Choice(__name__='contact_method',
title=_("How do people contact this team's members?"),
required=True, vocabulary=SimpleVocabulary(terms)))
def validate(self, data):
"""Validate the team contact email address.
Validation only occurs if the user wants to use an external address,
and the given email address is not already in use by this team.
This also ensures the mailing list is active if the HOSTED_LIST option
has been chosen.
"""
if data['contact_method'] == TeamContactMethod.EXTERNAL_ADDRESS:
email = data['contact_address']
if not email:
self.setFieldError(
'contact_address',
'Enter the contact address you want to use for this team.')
return
email = getUtility(IEmailAddressSet).getByEmail(
data['contact_address'])
if email is None or email.person != self.context:
try:
validate_new_team_email(data['contact_address'])
except LaunchpadValidationError, error:
# We need to wrap this in structured, so that the
# markup is preserved. Note that this puts the
# responsibility for security on the exception thrower.
self.setFieldError('contact_address',
structured(str(error)))
elif data['contact_method'] == TeamContactMethod.HOSTED_LIST:
mailing_list = getUtility(IMailingListSet).get(self.context.name)
if mailing_list is None or not mailing_list.is_usable:
self.addError(
"This team's mailing list is not active and may not be "
"used as its contact address yet")
else:
# Nothing to validate!
pass
@property
def initial_values(self):
"""Infer the contact method from this team's preferredemail.
Return a dictionary representing the contact_address and
contact_method so inferred.
"""
context = self.context
if context.preferredemail is None:
return dict(contact_method=TeamContactMethod.NONE)
mailing_list = getUtility(IMailingListSet).get(context.name)
if (mailing_list is not None
and mailing_list.address == context.preferredemail.email):
return dict(contact_method=TeamContactMethod.HOSTED_LIST)
return dict(contact_address=context.preferredemail.email,
contact_method=TeamContactMethod.EXTERNAL_ADDRESS)
@action('Change', name='change')
def change_action(self, action, data):
"""Changes the contact address for this mailing list."""
context = self.context
email_set = getUtility(IEmailAddressSet)
list_set = getUtility(IMailingListSet)
contact_method = data['contact_method']
if contact_method == TeamContactMethod.NONE:
context.setContactAddress(None)
elif contact_method == TeamContactMethod.HOSTED_LIST:
mailing_list = list_set.get(context.name)
assert mailing_list is not None and mailing_list.is_usable, (
"A team can only use a usable mailing list as its contact "
"address.")
email = email_set.getByEmail(mailing_list.address)
assert email is not None, (
"Cannot find mailing list's posting address")
context.setContactAddress(email)
elif contact_method == TeamContactMethod.EXTERNAL_ADDRESS:
contact_address = data['contact_address']
email = email_set.getByEmail(contact_address)
if email is None:
generateTokenAndValidationEmail(contact_address, context)
self.request.response.addInfoNotification(
"A confirmation message has been sent to '%s'. Follow "
"the instructions in that message to confirm the new "
"contact address for this team. (If the message "
"doesn't arrive in a few minutes, your mail provider "
"might use 'greylisting', which could delay the "
"message for up to an hour or two.)" % contact_address)
else:
context.setContactAddress(email)
else:
raise UnexpectedFormData(
"Unknown contact_method: %s" % contact_method)
@property
def next_url(self):
return canonical_url(self.context)
cancel_url = next_url
class TeamMailingListConfigurationView(MailingListTeamBaseView):
"""A view for creating and configuring a team's mailing list.
Allows creating a request for a list, cancelling the request,
setting the welcome message, deactivating, and reactivating the
list.
"""
schema = IMailingList
field_names = ['welcome_message']
label = "Mailing list configuration"
custom_widget('welcome_message', TextAreaWidget, width=72, height=10)
page_title = label
def __init__(self, context, request):
"""Set feedback messages for users who want to edit the mailing list.
There are a number of reasons why your changes to the mailing
list might not take effect immediately. First, the mailing
list may not actually be set as the team contact
address. Second, the mailing list may be in a transitional
state: from MODIFIED to UPDATING to ACTIVE can take a while.
"""
super(TeamMailingListConfigurationView, self).__init__(
context, request)
list_set = getUtility(IMailingListSet)
self.mailing_list = list_set.get(self.context.name)
@action('Save', name='save')
def save_action(self, action, data):
"""Sets the welcome message for a mailing list."""
welcome_message = data.get('welcome_message')
assert (self.mailing_list is not None
and self.mailing_list.is_usable), (
"Only a usable mailing list can be configured.")
if (welcome_message is not None
and welcome_message != self.mailing_list.welcome_message):
self.mailing_list.welcome_message = welcome_message
self.next_url = canonical_url(self.context)
def cancel_list_creation_validator(self, action, data):
"""Validator for the `cancel_list_creation` action.
Adds an error if someone tries to cancel a request that's
already been approved or declined. This can only happen
through bypassing the UI.
"""
mailing_list = getUtility(IMailingListSet).get(self.context.name)
if self.getListInState(MailingListStatus.REGISTERED) is None:
self.addError("This application can't be cancelled.")
@action('Cancel Application', name='cancel_list_creation',
validator=cancel_list_creation_validator)
def cancel_list_creation(self, action, data):
"""Cancels a pending mailing list registration."""
mailing_list_set = getUtility(IMailingListSet)
mailing_list_set.get(self.context.name).cancelRegistration()
self.request.response.addInfoNotification(
"Mailing list application cancelled.")
self.next_url = canonical_url(self.context)
def request_list_creation_validator(self, action, data):
"""Validator for the `request_list_creation` action.
Adds an error if someone tries to request a mailing list for a
team that already has one. This can only happen through
bypassing the UI.
"""
if not self.list_can_be_requested:
self.addError(
"You cannot request a new mailing list for this team.")
@action('Apply for Mailing List', name='request_list_creation',
validator=request_list_creation_validator)
def request_list_creation(self, action, data):
"""Creates a new mailing list."""
getUtility(IMailingListSet).new(self.context)
self.request.response.addInfoNotification(
"Mailing list requested and queued for approval.")
self.next_url = canonical_url(self.context)
def deactivate_list_validator(self, action, data):
"""Adds an error if someone tries to deactivate a non-active list.
This can only happen through bypassing the UI.
"""
if not self.list_can_be_deactivated:
self.addError("This list can't be deactivated.")
@action('Deactivate this Mailing List', name='deactivate_list',
validator=deactivate_list_validator)
def deactivate_list(self, action, data):
"""Deactivates a mailing list."""
getUtility(IMailingListSet).get(self.context.name).deactivate()
self.request.response.addInfoNotification(
"The mailing list will be deactivated within a few minutes.")
self.next_url = canonical_url(self.context)
def reactivate_list_validator(self, action, data):
"""Adds an error if a non-deactivated list is reactivated.
This can only happen through bypassing the UI.
"""
if not self.list_can_be_reactivated:
self.addError("Only a deactivated list can be reactivated.")
@action('Reactivate this Mailing List', name='reactivate_list',
validator=reactivate_list_validator)
def reactivate_list(self, action, data):
getUtility(IMailingListSet).get(self.context.name).reactivate()
self.request.response.addInfoNotification(
"The mailing list will be reactivated within a few minutes.")
self.next_url = canonical_url(self.context)
def purge_list_validator(self, action, data):
"""Adds an error if the list is not safe to purge.
This can only happen through bypassing the UI.
"""
if not self.list_can_be_purged:
self.addError('This list cannot be purged.')
@action('Purge this Mailing List', name='purge_list',
validator=purge_list_validator)
def purge_list(self, action, data):
getUtility(IMailingListSet).get(self.context.name).purge()
self.request.response.addInfoNotification(
'The mailing list has been purged.')
self.next_url = canonical_url(self.context)
@property
def list_is_usable_but_not_contact_method(self):
"""The list could be the contact method for its team, but isn't.
The list exists and is usable, but isn't set as the contact
method.
"""
return (self.list_is_usable and
(self.context.preferredemail is None or
self.mailing_list.address !=
self.context.preferredemail.email))
@property
def mailing_list_status_message(self):
"""A status message describing the state of the mailing list.
This status message helps a user be aware of behind-the-scenes
processes that would otherwise manifest only as mysterious
failures and inconsistencies.
"""
contact_admin = (
'Please '
'<a href="https://answers.launchpad.net/launchpad/+faq/197">'
'contact a Launchpad administrator</a> for further assistance.')
if (self.mailing_list is None or
self.mailing_list.status == MailingListStatus.PURGED):
# Purged lists act as if they don't exist.
return None
elif self.mailing_list.status == MailingListStatus.REGISTERED:
return None
elif self.mailing_list.status in [MailingListStatus.APPROVED,
MailingListStatus.CONSTRUCTING]:
return _("This team's mailing list will be available within "
"a few minutes.")
elif self.mailing_list.status == MailingListStatus.DECLINED:
return _("The application for this team's mailing list has been "
'declined. ' + contact_admin)
elif self.mailing_list.status == MailingListStatus.ACTIVE:
return None
elif self.mailing_list.status == MailingListStatus.DEACTIVATING:
return _("This team's mailing list is being deactivated.")
elif self.mailing_list.status == MailingListStatus.INACTIVE:
return _("This team's mailing list has been deactivated.")
elif self.mailing_list.status == MailingListStatus.FAILED:
return _("This team's mailing list could not be created. " +
contact_admin)
elif self.mailing_list.status == MailingListStatus.MODIFIED:
return _("An update to this team's mailing list is pending "
"and has not yet taken effect.")
elif self.mailing_list.status == MailingListStatus.UPDATING:
return _("A change to this team's mailing list is currently "
"being applied.")
elif self.mailing_list.status == MailingListStatus.MOD_FAILED:
return _("This team's mailing list is in an inconsistent state "
'because a change to its configuration was not '
'applied. ' + contact_admin)
else:
raise AssertionError(
"Unknown mailing list status: %s" % self.mailing_list.status)
@property
def initial_values(self):
"""The initial value of welcome_message comes from the database.
:return: A dictionary containing the current welcome message.
"""
if self.mailing_list is not None:
return dict(welcome_message=self.mailing_list.welcome_message)
else:
return {}
@property
def list_application_can_be_cancelled(self):
"""Can this team's mailing list request be cancelled?
It can only be cancelled if its state is REGISTERED.
"""
return self.getListInState(MailingListStatus.REGISTERED) is not None
@property
def list_can_be_requested(self):
"""Can a mailing list be requested for this team?
It can only be requested if there's no mailing list associated with
this team, or the mailing list has been purged.
"""
mailing_list = getUtility(IMailingListSet).get(self.context.name)
return (mailing_list is None or
mailing_list.status == MailingListStatus.PURGED)
@property
def list_can_be_deactivated(self):
"""Is this team's list in a state where it can be deactivated?
The list must exist and be in the ACTIVE state.
"""
return self.getListInState(MailingListStatus.ACTIVE) is not None
@property
def list_can_be_reactivated(self):
"""Is this team's list in a state where it can be reactivated?
The list must exist and be in the INACTIVE state.
"""
return self.getListInState(MailingListStatus.INACTIVE) is not None
@property
def list_can_be_purged(self):
"""Is this team's list in a state where it can be purged?
The list must exist and be in one of the REGISTERED, DECLINED, FAILED,
or INACTIVE states. Further, the user doing the purging, must be
an owner, Launchpad administrator or mailing list expert.
"""
is_moderator = check_permission('launchpad.Moderate', self.context)
is_mailing_list_manager = check_permission(
'launchpad.Moderate', self.context)
if is_moderator or is_mailing_list_manager:
return self.getListInState(*PURGE_STATES) is not None
else:
return False
class TeamMailingListSubscribersView(LaunchpadView):
"""The list of people subscribed to a team's mailing list."""
max_columns = 4
@property
def label(self):
return ('Mailing list subscribers for the %s team' %
self.context.displayname)
@cachedproperty
def subscribers(self):
return BatchNavigator(
self.context.mailing_list.getSubscribers(), self.request)
def renderTable(self):
html = ['<table style="max-width: 80em">']
items = list(self.subscribers.currentBatch())
assert len(items) > 0, (
"Don't call this method if there are no subscribers to show.")
# When there are more than 10 items, we use multiple columns, but
# never more columns than self.max_columns.
columns = int(math.ceil(len(items) / 10.0))
columns = min(columns, self.max_columns)
rows = int(math.ceil(len(items) / float(columns)))
for i in range(0, rows):
html.append('<tr>')
for j in range(0, columns):
index = i + (j * rows)
if index >= len(items):
break
subscriber_link = PersonFormatterAPI(items[index]).link(None)
html.append(
'<td style="width: 20em">%s</td>' % subscriber_link)
html.append('</tr>')
html.append('</table>')
return '\n'.join(html)
class TeamMailingListModerationView(MailingListTeamBaseView):
"""A view for moderating the held messages of a mailing list."""
schema = Interface
label = 'Mailing list moderation'
def __init__(self, context, request):
"""Allow for review and moderation of held mailing list posts."""
super(TeamMailingListModerationView, self).__init__(context, request)
list_set = getUtility(IMailingListSet)
self.mailing_list = list_set.get(self.context.name)
if self.mailing_list is None:
self.request.response.addInfoNotification(
'%s does not have a mailing list.' % self.context.displayname)
return self.request.response.redirect(canonical_url(self.context))
@cachedproperty
def hold_count(self):
"""The number of message being held for moderator approval.
:return: Number of message being held for moderator approval.
"""
## return self.mailing_list.getReviewableMessages().count()
# This looks like it would be more efficient, but it raises
# LocationError.
return self.held_messages.currentBatch().listlength
@cachedproperty
def held_messages(self):
"""All the messages being held for moderator approval.
:return: Sequence of held messages.
"""
results = self.mailing_list.getReviewableMessages()
navigator = BatchNavigator(results, self.request)
navigator.setHeadings('message', 'messages')
return navigator
@action('Moderate', name='moderate')
def moderate_action(self, action, data):
"""Commits the moderation actions."""
# We're somewhat abusing LaunchpadFormView, so the interesting bits
# won't be in data. Instead, get it out of the request.
reviewable = self.hold_count
disposed_count = 0
actions = {}
form = self.request.form_ng
for field_name in form:
if (field_name.startswith('field.') and
field_name.endswith('')):
# A moderated message.
quoted_id = field_name[len('field.'):]
message_id = unquote(quoted_id)
actions[message_id] = form.getOne(field_name)
messages = self.mailing_list.getReviewableMessages(
message_id_filter=actions)
for message in messages:
action_name = actions[message.message_id]
# This essentially acts like a switch statement or if/elifs. It
# looks the action up in a map of allowed actions, watching out
# for bogus input.
try:
action, status = dict(
approve=(message.approve, PostedMessageStatus.APPROVED),
reject=(message.reject, PostedMessageStatus.REJECTED),
discard=(message.discard, PostedMessageStatus.DISCARDED),
# hold is a no-op. Using None here avoids the bogus input
# trigger.
hold=(None, None),
)[action_name]
except KeyError:
raise UnexpectedFormData(
'Invalid moderation action for held message %s: %s' %
(message.message_id, action_name))
if action is not None:
disposed_count += 1
action(self.user)
self.request.response.addInfoNotification(
'Held message %s; Message-ID: %s' % (
status.title.lower(), message.message_id))
still_held = reviewable - disposed_count
if still_held > 0:
self.request.response.addInfoNotification(
'Messages still held for review: %d of %d' %
(still_held, reviewable))
self.next_url = canonical_url(self.context)
class TeamAddView(TeamFormMixin, HasRenewalPolicyMixin, LaunchpadFormView):
"""View for adding a new team."""
page_title = 'Register a new team in Launchpad'
label = page_title
schema = ITeamCreation
custom_widget('teamowner', HiddenUserWidget)
custom_widget(
'renewal_policy', LaunchpadRadioWidget, orientation='vertical')
custom_widget(
'subscriptionpolicy', LaunchpadRadioWidgetWithDescription,
orientation='vertical')
custom_widget('teamdescription', TextAreaWidget, height=10, width=30)
def setUpFields(self):
"""See `LaunchpadViewForm`.
Only Launchpad Admins get to see the visibility field.
"""
super(TeamAddView, self).setUpFields()
self.conditionallyOmitVisibility()
@action('Create Team', name='create')
def create_action(self, action, data):
name = data.get('name')
displayname = data.get('displayname')
teamdescription = data.get('teamdescription')
defaultmembershipperiod = data.get('defaultmembershipperiod')
defaultrenewalperiod = data.get('defaultrenewalperiod')
subscriptionpolicy = data.get('subscriptionpolicy')
teamowner = data.get('teamowner')
team = getUtility(IPersonSet).newTeam(
teamowner, name, displayname, teamdescription,
subscriptionpolicy, defaultmembershipperiod, defaultrenewalperiod)
visibility = data.get('visibility')
if visibility:
team.visibility = visibility
email = data.get('contactemail')
if email is not None:
generateTokenAndValidationEmail(email, team)
self.request.response.addNotification(
"A confirmation message has been sent to '%s'. Follow the "
"instructions in that message to confirm the new "
"contact address for this team. "
"(If the message doesn't arrive in a few minutes, your mail "
"provider might use 'greylisting', which could delay the "
"message for up to an hour or two.)" % email)
self.next_url = canonical_url(team)
def _validateVisibilityConsistency(self, value):
"""See `TeamFormMixin`."""
return None
@property
def _visibility(self):
"""Return the visibility for the object.
For a new team it is PUBLIC unless otherwise set in the form data.
"""
return PersonVisibility.PUBLIC
@property
def _name(self):
return None
class ProposedTeamMembersEditView(LaunchpadFormView):
schema = Interface
label = 'Proposed team members'
@action('Save changes', name='save')
def action_save(self, action, data):
expires = self.context.defaultexpirationdate
statuses = dict(
approve=TeamMembershipStatus.APPROVED,
decline=TeamMembershipStatus.DECLINED,
)
target_team = self.context
failed_joins = []
for person in target_team.proposedmembers:
action = self.request.form.get('action_%d' % person.id)
status = statuses.get(action)
if status is None:
# The action is "hold" or no action was specified for this
# person, which could happen if the set of proposed members
# changed while the form was being processed.
continue
try:
target_team.setMembershipData(
person, status, reviewer=self.user, expires=expires,
comment=self.request.form.get('comment'))
except CyclicalTeamMembershipError:
failed_joins.append(person)
if len(failed_joins) > 0:
failed_names = [person.displayname for person in failed_joins]
failed_list = ", ".join(failed_names)
mapping=dict(
this_team=target_team.displayname,
failed_list=failed_list)
if len(failed_joins) == 1:
self.request.response.addInfoNotification(
_('${this_team} is a member of the following team, so it '
'could not be accepted: '
'${failed_list}. You need to "Decline" that team.',
mapping=mapping))
else:
self.request.response.addInfoNotification(
_('${this_team} is a member of the following teams, so '
'they could not be accepted: '
'${failed_list}. You need to "Decline" those teams.',
mapping=mapping))
self.next_url = ''
else:
self.next_url = self._next_url
@property
def page_title(self):
return 'Proposed members of %s' % self.context.displayname
@property
def _next_url(self):
return '%s/+members' % canonical_url(self.context)
cancel_url = _next_url
class TeamBrandingView(BrandingChangeView):
schema = ITeam
field_names = ['icon', 'logo', 'mugshot']
class ITeamMember(Interface):
"""The interface used in the form to add a new member to a team."""
newmember = PublicPersonChoice(
title=_('New member'), required=True,
vocabulary='ValidTeamMember',
description=_("The user or team which is going to be "
"added as the new member of this team."))
class TeamMemberAddView(LaunchpadFormView):
schema = ITeamMember
label = "Select the new member"
custom_widget(
'newmember', PersonPickerWidget,
show_assign_me_button=False, show_remove_button=False)
@property
def page_title(self):
return 'Add members to %s' % self.context.displayname
@property
def cancel_url(self):
return canonical_url(self.context)
def validate(self, data):
"""Verify new member.
This checks that the new member has some active members and is not
already an active team member.
"""
newmember = data.get('newmember')
error = None
if newmember is not None:
if newmember.isTeam() and not newmember.activemembers:
error = _("You can't add a team that doesn't have any active"
" members.")
elif newmember in self.context.activemembers:
error = _("%s (%s) is already a member of %s." % (
newmember.displayname, newmember.name,
self.context.displayname))
if error:
self.setFieldError("newmember", error)
@action(u"Add Member", name="add")
def add_action(self, action, data):
"""Add the new member to the team."""
newmember = data['newmember']
# If we get to this point with the member being the team itself,
# it means the ValidTeamMemberVocabulary is broken.
assert newmember != self.context, (
"Can't add team to itself: %s" % newmember)
changed, new_status = self.context.addMember(
newmember, reviewer=self.user,
status=TeamMembershipStatus.APPROVED)
if new_status == TeamMembershipStatus.INVITED:
msg = "%s has been invited to join this team." % (
newmember.unique_displayname)
else:
msg = "%s has been added as a member of this team." % (
newmember.unique_displayname)
self.request.response.addInfoNotification(msg)
# Clear the newmember widget so that the user can add another member.
self.widgets['newmember'].setRenderedValue(None)
class TeamMapView(LaunchpadView):
"""Show all people with known locations on a map.
Also provides links to edit the locations of people in the team without
known locations.
"""
label = "Team member locations"
limit = None
@cachedproperty
def mapped_participants(self):
"""Participants with locations."""
return self.context.getMappedParticipants(limit=self.limit)
@cachedproperty
def mapped_participants_count(self):
"""Count of participants with locations."""
return self.context.mapped_participants_count
@cachedproperty
def has_mapped_participants(self):
"""Does the team have any mapped participants?"""
return self.mapped_participants_count > 0
@cachedproperty
def unmapped_participants(self):
"""Participants (ordered by name) with no recorded locations."""
return list(self.context.unmapped_participants)
@cachedproperty
def unmapped_participants_count(self):
"""Count of participants with no recorded locations."""
return self.context.unmapped_participants_count
@cachedproperty
def times(self):
"""The current times in time zones with members."""
zones = set(participant.time_zone
for participant in self.mapped_participants)
times = [datetime.now(pytz.timezone(zone))
for zone in zones]
timeformat = '%H:%M'
return sorted(
set(time.strftime(timeformat) for time in times))
@cachedproperty
def bounds(self):
"""A dictionary with the bounds and center of the map, or None"""
if self.has_mapped_participants:
return self.context.getMappedParticipantsBounds(self.limit)
return None
@property
def map_html(self):
"""HTML which shows the map with location of the team's members."""
return """
<script type="text/javascript">
LPS.use('node', 'lp.app.mapping', function(Y) {
function renderMap() {
Y.lp.app.mapping.renderTeamMap(
%(min_lat)s, %(max_lat)s, %(min_lng)s,
%(max_lng)s, %(center_lat)s, %(center_lng)s);
}
Y.on("domready", renderMap);
});
</script>""" % self.bounds
@property
def map_portlet_html(self):
"""The HTML which shows a small version of the team's map."""
return """
<script type="text/javascript">
LPS.use('node', 'lp.app.mapping', function(Y) {
function renderMap() {
Y.lp.app.mapping.renderTeamMapSmall(
%(center_lat)s, %(center_lng)s);
}
Y.on("domready", renderMap);
});
</script>""" % self.bounds
class TeamMapData(TeamMapView):
"""An XML dump of the locations of all team members."""
def render(self):
self.request.response.setHeader(
'content-type', 'application/xml;charset=utf-8')
body = LaunchpadView.render(self)
return body.encode('utf-8')
class TeamMapLtdMixin:
"""A mixin for team views with limited participants."""
limit = 24
class TeamMapLtdView(TeamMapLtdMixin, TeamMapView):
"""Team map view with limited participants."""
class TeamMapLtdData(TeamMapLtdMixin, TeamMapData):
"""An XML dump of the locations of limited number of team members."""
class TeamHierarchyView(LaunchpadView):
"""View for ~team/+teamhierarchy page."""
@property
def label(self):
return 'Team relationships for ' + self.context.displayname
@property
def has_sub_teams(self):
return self.context.sub_teams.count() > 0
@property
def has_super_teams(self):
return self.context.super_teams.count() > 0
@property
def has_only_super_teams(self):
return self.has_super_teams and not self.has_sub_teams
@property
def has_only_sub_teams(self):
return not self.has_super_teams and self.has_sub_teams
@property
def has_relationships(self):
return self.has_sub_teams or self.has_super_teams
|