1
import itertools, random, re
2
from zope.interface import implements
4
from epsilon.extime import Time
6
from twisted.python.components import registerAdapter
8
from nevow import rend, static, tags, url
9
from nevow.athena import LiveElement, expose
10
from nevow.inevow import IResource
11
from nevow.page import renderer
12
from nevow.vhost import VHostMonsterResource
14
from axiom import batch, userbase
15
from axiom.attributes import AND, integer, reference, text, timestamp
16
from axiom.dependency import dependsOn
17
from axiom.item import Item, transacted
18
from axiom.scheduler import Scheduler
19
from axiom.upgrade import registerAttributeCopyingUpgrader
21
from xmantissa.fulltext import SQLiteIndexer
22
from xmantissa.ixmantissa import (IFulltextIndexable, INavigableElement,
23
INavigableFragment, ISessionlessSiteRootPlugin, ITemplateNameResolver)
24
from xmantissa.scrolltable import InequalityModel
25
from xmantissa.sharing import getEveryoneRole, itemFromProxy
26
from xmantissa.webapp import PrivateApplication
27
from xmantissa.webnav import Tab
28
from xmantissa.websharing import linkTo
29
from xmantissa.website import PrefixURLMixin
30
from xmantissa.webtheme import ThemedDocumentFactory
32
from methanal.model import Model, ValueParameter
33
from methanal.view import CheckboxInput, LiveForm, SelectInput, TextInput
35
from circus.color import colorizeLine
36
from circus.icircus import (IQuote, IQuoteAdder, IQuoteDatabase,
37
IQuoteModeration, IQuoteSearcher)
38
from circus.roles import getModeratorsRole, getUserRole, inModerators
39
from circus.util import getServiceProvider
40
from circus.widgets import TabView
44
class Redirector(Item):
46
Redirect users away from C{/private}.
48
implements(INavigableElement)
50
typeName = 'flyingcircus_redirector'
52
powerupInterfaces = [INavigableElement]
54
privateApplication = dependsOn(PrivateApplication)
58
return [Tab('Quotes', self.storeID, 1.0, authoritative=True, children=[
59
Tab('Overview', self.storeID, 1.0)])]
63
class RedirectorResource(object):
67
def __init__(self, redirector):
71
def renderHTTP(self, ctx):
72
# XXX: We should probably be generating a link from the appstore share
74
return url.root.child('FlyingCircus')
76
registerAdapter(RedirectorResource, Redirector, IResource)
82
Personalised quote view.
84
implements(INavigableElement)
86
typeName = 'flyingcircus_myquotes'
88
powerupInterfaces = [INavigableElement]
90
privateApplication = dependsOn(PrivateApplication)
94
return [Tab('Quotes', self.storeID, 1.0, authoritative=False, children=[
95
Tab('My Quotes', self.storeID, 0.8)])]
99
class MyQuotesView(TabView):
101
Personalised quote view.
103
implements(INavigableFragment)
105
title = u'Slipgate Quote Database - My Quotes'
106
viewTitle = u'My Quotes'
111
def __init__(self, myquotes):
112
self.myquotes = myquotes
113
self.store = self.myquotes.store
115
self.resolver = ITemplateNameResolver(self.store.parent)
117
quoteDB = getServiceProvider(self.store, IQuoteDatabase)
118
userID = u'@'.join(userbase.getAccountNames(self.store).next())
120
appStore = quoteDB.store
122
role = getUserRole(appStore, userID)
124
def pagerTab(title, status, allUsers=False, sortAscending=False):
125
criteria = [Quote.status == status]
127
criteria.append(Quote.userID == userID)
129
baseConstraint = AND(*criteria)
130
pager = QuotePager(store=appStore,
131
resolver=self.resolver,
133
attribute=Quote.added,
134
sortAscending=sortAscending,
136
baseConstraint=baseConstraint)
137
return QuotesTab(title=title,
139
resolver=self.resolver)
142
pagerTab(u'Accepted', u'accepted'),
143
pagerTab(u'Rejected', u'rejected'),
146
if inModerators(role):
147
tabs.append(pagerTab(u'Moderation', u'moderate', True, True))
149
super(MyQuotesView, self).__init__(
150
resolver=self.resolver,
153
registerAdapter(MyQuotesView, MyQuotes, INavigableFragment)
158
implements(IQuoteAdder, IQuoteDatabase, IQuoteSearcher)
160
typeName = 'flyingcircus_quotedb'
162
powerupInterfaces = [IQuoteDatabase]
164
lastQid = integer(default=0)
166
scheduler = dependsOn(Scheduler)
167
searchIndexer = dependsOn(SQLiteIndexer)
170
def getQuotesByIDs(self, quoteIDs, sort=None):
172
Query for L{Quote}s by their IDs.
174
return self.store.query(Quote,
175
Quote.qid.oneOf(quoteIDs),
181
def addQuote(self, content, userID):
182
self.lastQid = self.lastQid + 1
183
quote = Quote(store=self.store,
188
createParticipants(quote)
189
return shareQuote(quote)
193
def getQuote(self, qid):
194
return self.store.findUnique(Quote,
198
def getRandomQuotes(self, limit=None):
199
query = self.store.query(Quote,
200
Quote.status != u'rejected')
202
numQuotes = query.count()
203
if limit is not None:
204
numQuotes = min(numQuotes, limit)
206
# XXX: This isn't very efficient.
207
quoteIDs = list(query.getColumn('qid'))
208
quoteIDs = random.sample(quoteIDs, numQuotes)
209
return self.getQuotesByIDs(quoteIDs)
213
def search(self, term, sort=None, limit=None):
214
def getQuotes(results):
215
quoteIDs = [r.uniqueIdentifier for r in results]
216
return self.getQuotesByIDs(quoteIDs, sort)
218
return self.searchIndexer.search(term, count=limit
219
).addCallback(getQuotes)
221
registerAttributeCopyingUpgrader(QuoteDB, 1, 2)
222
registerAttributeCopyingUpgrader(QuoteDB, 2, 3)
226
class QuoteDBView(LiveElement):
228
View for listing quotes from an L{IQuoteDatabase}.
230
implements(INavigableFragment)
232
title = u'Slipgate Quote Database'
233
docFactory = ThemedDocumentFactory('qdb-view', 'resolver')
238
def __init__(self, quoteDB, userID=None):
239
super(QuoteDBView, self).__init__()
240
self.quoteDB = quoteDB
243
self.appStore = itemFromProxy(self.quoteDB).store
244
self.resolver = ITemplateNameResolver(self.appStore.parent)
245
self.role = getUserRole(self.appStore, userID)
249
def content(self, req, tag):
250
def firstOr(seq, default=None):
256
sortType = firstOr(args.get('sort'))
257
sortAscending = firstOr(args.get('dir'), 'desc') != 'desc'
258
limit = firstOr(args.get('limit'), self.LIMIT)
259
limit = min(int(limit), self.LIMIT)
261
baseConstraint = Quote.status != u'rejected'
264
if sortType is not None:
265
attribute = translateSortType(sortType, None)
266
if attribute is not None:
267
elem = QuotePager(store=self.appStore,
268
resolver=self.resolver,
271
sortAscending=sortAscending,
273
baseConstraint=baseConstraint)
274
elif sortType == 'random':
275
query = self.quoteDB.getRandomQuotes(limit)
276
quotes = list(self.role.asAccessibleTo(query))
277
elem = QuoteList(quotes=quotes,
278
resolver=self.resolver)
280
elem = QuoteDatabaseOverview(store=self.appStore,
282
resolver=self.resolver,
285
elem.setFragmentParent(self)
290
def customizeFor(self, userID):
291
return type(self)(self.quoteDB, userID)
293
registerAdapter(QuoteDBView, IQuoteDatabase, INavigableFragment)
297
class QuoteSearchView(LiveElement):
299
A view for performing quote searches.
301
implements(INavigableFragment)
303
title = u'Slipgate Quote Database - Search'
304
docFactory = ThemedDocumentFactory('qdb-search', 'resolver')
305
jsClass = u'FlyingCircus.Search.QuoteSearch'
310
def __init__(self, quoteDB, userID=None):
311
super(QuoteSearchView, self).__init__()
312
self.quoteDB = quoteDB
315
appStore = itemFromProxy(quoteDB).store
316
self.resolver = ITemplateNameResolver(appStore.parent)
317
self.role = getUserRole(appStore, self.userID)
320
def performSearch(self, term, sortType, sortAscending):
321
def makeResultList(query):
322
quotes = self.role.asAccessibleTo(query)
323
elem = QuoteList(quotes=quotes,
324
resolver=self.resolver)
325
elem.setFragmentParent(self)
326
return elem, query.count()
328
sort = translateSortType(sortType, sortAscending)
330
return self.quoteDB.search(term=term, sort=sort, limit=self.LIMIT
331
).addCallback(makeResultList)
335
def searchForm(self, req, tag):
336
model = Model(callback=self.performSearch,
337
params=[ValueParameter(name='term',
340
ValueParameter(name='sortType',
343
ValueParameter(name='sortAscending',
347
form = LiveForm(store=None, model=model, fragmentParent=self)
348
form.jsClass = u'FlyingCircus.Search.QuoteSearchForm'
349
TextInput(parent=form, name='term')
352
(u'rating', u'Rating'),
354
SelectInput(parent=form, name='sortType', values=sortValues)
355
CheckboxInput(parent=form, name='sortAscending')
361
def customizeFor(self, userID):
362
return type(self)(self.quoteDB, userID)
364
registerAdapter(QuoteSearchView, IQuoteSearcher, INavigableFragment)
368
class QuoteAdderView(LiveElement):
370
View for L{IQuoteAdder}s.
372
Providers users with an interface for submitting a new quote.
374
implements(INavigableFragment)
376
title = u'Slipgate Quote Database - Add Quote'
377
docFactory = ThemedDocumentFactory('add-quote', 'resolver')
378
jsClass = u'FlyingCircus.Quotes.QuoteAdder'
381
def __init__(self, quoteAdder):
382
super(QuoteAdderView, self).__init__()
383
self.quoteAdder = quoteAdder
385
appStore = itemFromProxy(self.quoteAdder).store
386
self.resolver = ITemplateNameResolver(appStore.parent)
391
def addQuote(self, content):
392
quote = self.quoteAdder.addQuote(content, self.userID)
398
def customizeFor(self, userID):
402
registerAdapter(QuoteAdderView, IQuoteAdder, INavigableFragment)
407
implements(IQuote, IQuoteModeration, IFulltextIndexable)
409
typeName = 'flyingcircus_quote'
412
qid = integer(allowNone=False, indexed=True)
413
content = text(allowNone=False)
414
added = timestamp(allowNone=False, indexed=True, defaultFactory=lambda: Time())
415
votesFor = integer(allowNone=False, default=0)
416
votesAgainst = integer(allowNone=False, default=0)
417
rating = integer(allowNone=False, default=0, indexed=True)
418
votes = integer(allowNone=False, default=0)
420
status = text(allowNone=False, default=u'accepted')
424
return '<%s qid=%s rating=%d votes=%d added=%r>' % (
429
self.added.asHumanly())
433
# Tell the batch processor that we have data to index.
434
qs = self.store.findUnique(QuoteSource)
441
self.votesFor = self.votesFor + 1
442
self.rating = self.rating + 1
443
self.votes = self.votes + 1
448
self.votesAgainst = self.votesAgainst + 1
449
self.rating = self.rating - 1
450
self.votes = self.votes + 1
454
def requestModeration(self):
455
self.status = u'moderate'
458
def getParticipants(self):
459
return self.store.query(Clown, Clown.quote == self).getColumn('nickname')
462
def getUsername(self):
464
if userID is not None:
465
return userID.split(u'@', 1)[0]
472
self.status = u'rejected'
477
self.status = u'accepted'
481
def uniqueIdentifier(self):
486
return [self.content]
488
registerAttributeCopyingUpgrader(Quote, 1, 2)
490
QuoteSource = batch.processor(Quote)
494
class QuoteView(LiveElement):
496
Navigable resource for a single L{IQuote}.
498
Visiting the C{raw} child resource results in a stripped down quote.
500
implements(INavigableFragment)
502
docFactory = ThemedDocumentFactory('qdb-view', 'resolver')
505
def __init__(self, quote):
506
super(QuoteView, self).__init__()
509
appStore = itemFromProxy(self.quote).store
510
self.resolver = ITemplateNameResolver(appStore.parent)
513
def locateChild(self, ctx, segments):
514
if segments and segments[0] == 'raw':
516
lines = quote.content.splitlines()
517
lines.insert(0, '#%s (%s/%s)' % (quote.qid, quote.rating, quote.votes))
518
text = '\n'.join(lines)
519
return static.Data(text.encode('utf-8'), 'text/plain; charset=UTF-8'), ()
526
return u'Slipgate Quote Database - #%s' % (self.quote.qid,)
530
def content(self, req, tag):
531
elem = QuoteElement(quote=self.quote,
532
resolver=self.resolver)
533
elem.setFragmentParent(self)
536
registerAdapter(QuoteView, IQuote, INavigableFragment)
542
A participant in a L{Quote}.
544
typeName = 'flyingcircus_clown'
547
quote = reference(doc="""
548
L{Quote} this participant is involved in.
549
""", allowNone=False, reftype=Quote, indexed=True)
551
nickname = text(doc="""
552
The participant's nickname.
553
""", allowNone=False)
557
class QuotesTab(LiveElement):
559
A simple container, intended for use with C{FragmentCollector}.
561
implements(INavigableFragment)
563
docFactory = ThemedDocumentFactory('quotes-tab', 'resolver')
566
def __init__(self, title, content, resolver):
568
Initialise the container.
570
@type title: C{unicode}
571
@param title: Title for the tab
573
@type content: C{renderable}
574
@param content: Renderable tab content
576
@type resolver: C{ITemplateNameResolver}
577
@param resolver: Template resolver
579
super(QuotesTab, self).__init__()
580
self.title = title.encode('utf-8')
581
self.content = content
582
self.content.setFragmentParent(self)
583
self.resolver = resolver
587
def tabContent(self, req, tag):
588
return tag[self.content]
597
class QuoteDatabaseOverview(LiveElement):
599
Overview for a quote database.
601
docFactory = ThemedDocumentFactory('qdb-overview', 'resolver')
606
def __init__(self, store, role, resolver, userID=None):
607
super(QuoteDatabaseOverview, self).__init__()
610
self.resolver = resolver
614
def makePager(self, attribute):
615
baseConstraint = Quote.status != u'rejected'
616
elem = QuotePager(store=self.store,
617
resolver=self.resolver,
622
baseConstraint=baseConstraint)
623
elem.setFragmentParent(self)
628
def recent(self, req, tag):
629
return tag[self.makePager(Quote.added)]
633
def top(self, req, tag):
634
return tag[self.makePager(Quote.rating)]
638
def signupNag(self, req, tag):
639
if self.userID is None:
645
class QuoteElement(LiveElement):
647
View for a single quote.
649
An Athena interface for voting and retrieving the quote rating is exposed.
651
If the quote has participant information, it will be used for highlighting.
653
docFactory = ThemedDocumentFactory('quote', 'resolver')
654
jsClass = u'FlyingCircus.Quotes.Quote'
656
_statusIndicators = {
661
def __init__(self, quote, resolver):
663
Initialise the element.
665
@type quote: C{SharedProxy} for an L{IQuote} interface
667
@type resolver: C{ITemplateNameResolver}
669
super(QuoteElement, self).__init__()
671
self.resolver = resolver
674
def getInitialArguments(self):
675
return [self.getRating()]
678
def getStatusInfo(self):
680
Get information related to the current quote status.
682
@rtype: C{(unicode, unicode)}
683
@return: C{(statusIndicator, statusCSSClassName)}
685
status = self.quote.status
686
return self._statusIndicators.get(status, u''), u'status-%s' % (status,)
689
def colorizeContent(self):
691
Highlight nicknames in the quote content.
693
@rtype: C{iterable} of renderable elements, each one representing
694
a line from the quote content
696
def nickNode(nickname, color):
697
style = 'color: #%x%x%x' % color
698
return tags.span(style=style)[nickname]
700
return (colorizeLine(nickNode, line, self.quote.getParticipants())
701
for line in self.quote.content.strip().splitlines())
706
return self.quote.rating, self.quote.votes
712
return self.getRating()
718
return self.getRating()
723
self.quote.requestModeration()
724
return self.getStatusInfo()
730
return self.getStatusInfo()
736
return self.getStatusInfo()
740
def quoteInfo(self, req, tag):
742
tag.fillSlots('permalink', linkTo(quote))
743
tag.fillSlots('qid', quote.qid)
744
statusIndicator, statusClass = self.getStatusInfo()
745
tag.fillSlots('statusClass', statusClass)
746
tag.fillSlots('statusIndicator', statusIndicator)
747
tag.fillSlots('created', quote.added.asHumanly())
748
username = quote.getUsername()
749
if username is not None:
750
username = [u'by ', tags.strong[username]]
753
tag.fillSlots('user', username)
759
def quoteContent(self, req, tag):
760
content = itertools.izip(self.colorizeContent(),
761
itertools.repeat(tags.br))
766
def moderation(self, req, tag):
767
status = self.quote.status
770
if IQuoteModeration.providedBy(self.quote):
771
if status != u'rejected':
772
yield tag.onePattern('rejectButton')
773
if status == u'moderate':
774
yield tag.onePattern('acceptButton')
775
elif status == u'accepted':
776
yield tag.onePattern('moderateButton')
778
return tag[buttons()]
782
class PagerInequalityModel(InequalityModel):
784
InequalityModel for paginated quote views.
786
def __init__(self, store, role, attribute, sortAscending, baseConstraint):
787
super(PagerInequalityModel, self).__init__(store=store,
789
baseConstraint=baseConstraint,
791
defaultSortColumn=attribute,
792
defaultSortAscending=False)
794
self.sortAscending = sortAscending
797
def constructRows(self, query):
798
rows = list(self.role.asAccessibleTo(query))
799
if not self.sortAscending:
800
rows = list(reversed(rows))
805
class QuotePager(LiveElement):
807
A paginated quote list view.
809
docFactory = ThemedDocumentFactory('quote-pager', 'resolver')
810
jsClass = u'FlyingCircus.Quotes.QuotePager'
813
def __init__(self, store, resolver, role, attribute, sortAscending, limit, baseConstraint=None):
815
Initialise the pager element.
817
@type store: Axiom store
818
@param store: Store to query for L{Quote}s in
820
@type resolver: C{ITemplateNameResolver}
822
@type role: C{xmantissa.sharing.Role}
823
@param role: Role to use when exposing shared items to the view
825
@type attribute: Axiom item attribute
826
@param attribute: L{Quote} attribute to use for queries and sorting
828
@type sortAscending: C{boolean}
829
@param sortAscending: Determine whether to sort the query results
833
@param limit: Number of items per page
835
@type baseConstraint: Axiom query or C{None}
836
@param baseConstraint: Base constraint to apply to queries or C{None}
838
super(QuotePager, self).__init__()
840
self.resolver = resolver
841
self.attribute = attribute
842
self.sortAscending = sortAscending
845
self.firstValue = self.lastValue = None
846
self.imodel = PagerInequalityModel(store=self.store,
848
attribute=self.attribute,
849
sortAscending=sortAscending,
850
baseConstraint=baseConstraint)
852
getters = [self.imodel.rowsBeforeItem, self.imodel.rowsAfterItem]
853
getNext, getPrev = getters[sortAscending], getters[not sortAscending]
855
self.getNext = lambda: getNext(self.lastValue, self.limit)
856
self.getPrev = lambda: getPrev(self.firstValue, self.limit)
859
def makeQuoteList(self, quotes, allowEmpty=False):
861
Create a L{QuoteList} widget.
863
@type quotes: C{sequence} of C{SharedProxy}s implementing L{IQuote}
865
@param allowEmpty: Create an empty L{QuoteList} if L{quotes} is empty
867
if not quotes and not allowEmpty:
871
self.firstValue = itemFromProxy(quotes[0])
872
self.lastValue = itemFromProxy(quotes[-1])
874
elem = QuoteList(quotes=quotes,
875
resolver=self.resolver)
876
elem.setFragmentParent(self)
881
def quoteList(self, req, tag):
882
if self.sortAscending:
883
quotes = self.imodel.rowsAfterValue(None, self.limit)
885
quotes = self.imodel.rowsBeforeValue(None, self.limit)
886
return tag[self.makeQuoteList(quotes, True)]
892
Retrieve a widget containing the next page of quotes.
895
if self.lastValue is not None:
896
quotes = self.getNext()
897
return self.makeQuoteList(quotes)
903
Retrieve a widget containing the previous page of quotes.
906
if self.firstValue is not None:
907
quotes = self.getPrev()
908
return self.makeQuoteList(quotes)
912
class QuoteList(LiveElement):
914
View for multiple quotes.
916
Quotes will be zebra-striped and if there are no quotes an "empty" view
919
docFactory = ThemedDocumentFactory('quote-list', 'resolver')
922
def __init__(self, quotes, resolver):
924
Initialise quote list element.
926
@type quotes: C{iterable} of C{SharedProxy}s for L{IQuote}
928
super(QuoteList, self).__init__()
929
self.quotes = list(quotes)
930
self.resolver = resolver
934
def quoteList(self, req, tag):
936
return tag[tag.onePattern('empty')]
938
oddeven = itertools.cycle(['even', 'odd'])
941
for quote, cls in itertools.izip(self.quotes, oddeven):
942
elem = QuoteElement(quote=quote,
943
resolver=self.resolver)
944
elem.setFragmentParent(self)
945
yield tags.div(class_=cls)[elem]
947
return tag[content()]
951
class VHost(Item, PrefixURLMixin):
952
implements(ISessionlessSiteRootPlugin)
954
typeName = 'flyingcircus_vhost'
959
prefixURL = text(default=u'vhost')
961
def createResource(self):
962
return VHostMonsterResource()
966
_nicknamePatterns = [
967
re.compile(r'<[ +%@&~]?(.*?) ?> '), # Addressing: < foo> hi <@bar> hi there
968
re.compile(r'^\s*\* (.*?) '), # Actions: * foo does a thing
971
def extractParticipants(content):
973
Extract a list of unique nicknames that participate in a quote.
975
@param content: Text representation of a quote's content
976
@type content: C{unicode}
978
@rtype: C{tuple} of C{unicode}
979
@return: The featured nicknames
983
for line in content.splitlines():
984
for pattern in _nicknamePatterns:
985
match = pattern.search(line)
986
if match is not None and match.group(1):
987
nicknames.add(match.group(1))
990
return tuple(nicknames)
994
def shareQuote(quote):
996
Share a L{Quote} item.
998
shareID = unicode(quote.qid)
1000
sharedQuote = getEveryoneRole(quote.store).shareItem(
1003
interfaces=[IQuote])
1005
modRole = getModeratorsRole(quote.store)
1009
interfaces=[IQuoteModeration])
1016
def createParticipants(quote):
1018
Create L{Clown} items for each participant in C{quote}.
1020
@type quote: L{Quote}
1023
store.query(Clown, Clown.quote == quote).deleteFromStore();
1025
for nickname in extractParticipants(quote.content):
1033
'date': Quote.added,
1034
'rating': Quote.rating}
1036
def translateSortType(sortType, sortAscending):
1037
attribute = _sortTypes.get(sortType)
1038
if attribute is not None and sortAscending is not None:
1039
direction = ['descending', 'ascending'][sortAscending]
1040
attribute = getattr(attribute, direction)