diff --git a/r2/r2/config/routing.py b/r2/r2/config/routing.py index 1603d86e..fe3b519b 100644 --- a/r2/r2/config/routing.py +++ b/r2/r2/config/routing.py @@ -104,6 +104,7 @@ def make_map(global_conf={}, app_conf={}): mc('/meetups/:id/edit', action='edit', controller='meetups') mc('/meetups/:id/update', action='update', controller='meetups') mc('/meetups/:id', action='show', controller='meetups') + mc('/cancelmeetup/:key', action='stopmeetup', controller='meetups') mc('/tag/:tag', controller='tag', action='listing', where='tag') diff --git a/r2/r2/controllers/meetupscontroller.py b/r2/r2/controllers/meetupscontroller.py index 53c7004a..86681d50 100644 --- a/r2/r2/controllers/meetupscontroller.py +++ b/r2/r2/controllers/meetupscontroller.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta import json +import calendar from mako.template import Template from pylons.i18n import _ @@ -10,10 +11,10 @@ from r2.lib.filters import python_websafe from r2.lib.jsonresponse import Json from r2.lib.menus import CommentSortMenu,NumCommentsMenu -from r2.lib.pages import BoringPage, ShowMeetup, NewMeetup, EditMeetup, PaneStack, CommentListing, LinkInfoPage, CommentReplyBox, NotEnoughKarmaToPost -from r2.models import Meetup,Link,Subreddit,CommentBuilder,PendingJob +from r2.lib.pages import BoringPage, ShowMeetup, NewMeetup, EditMeetup, PaneStack, CommentListing, LinkInfoPage, CommentReplyBox, NotEnoughKarmaToPost, CancelMeetup, UnfoundPage +from r2.models import Meetup,Link,Subreddit,CommentBuilder,PendingJob,Account from r2.models.listing import NestedListing -from validator import validate, VUser, VModhash, VRequired, VMeetup, VEditMeetup, VFloat, ValueOrBlank, ValidIP, VMenu, VCreateMeetup, VTimestamp +from validator import validate, VUser, VModhash, VRequired, VMeetup, VEditMeetup, VFloat, ValueOrBlank, ValidIP, VMenu, VCreateMeetup, VTimestamp, nop from routes.util import url_for @@ -29,6 +30,28 @@ def meetup_article_text(meetup): def meetup_article_title(meetup): return "Meetup : %s"%meetup.title +def calculate_month_interval(date): + """Calculates the number of days between a date + and the same day (ie 3rd Wednesday) in the + following month""" + + day_of_week = date.weekday() + week_of_month = (date.day-1)/7 + this_month = (date.year, date.month) + if this_month[1] != 12: + next_month = (this_month[0], this_month[1]+1) + else: + next_month = (this_month[0] + 1, 1) + this_month = calendar.monthrange(*this_month) + next_month = calendar.monthrange(*next_month) + remaining_days_in_month = this_month[1] - date.day + """this line calculates the nth day of the next month, + if that exceeds the number of days in that month, it + backs off a week""" + days_into_next_month = (day_of_week - next_month[0])%7 + (week_of_month * 7 + 1) if (day_of_week - next_month[0])%7 + week_of_month * 7 + 1 < next_month[1] else (day_of_week - next_month[0])%7 + (week_of_month-1) * 7 + 1 + offset = remaining_days_in_month + days_into_next_month + return offset + class MeetupsController(RedditController): def response_func(self, **kw): return self.sendstring(json.dumps(kw)) @@ -56,21 +79,45 @@ def GET_new(self, *a, **kw): latitude = VFloat('latitude', error=errors.NO_LOCATION), longitude = VFloat('longitude', error=errors.NO_LOCATION), timestamp = VTimestamp('timestamp'), - tzoffset = VFloat('tzoffset', error=errors.INVALID_DATE)) - def POST_create(self, res, title, description, location, latitude, longitude, timestamp, tzoffset, ip): + tzoffset = VFloat('tzoffset', error=errors.INVALID_DATE), + recurring = nop('recurring')) + def POST_create(self, res, title, description, location, latitude, longitude, timestamp, tzoffset, ip, recurring): if res._chk_error(errors.NO_TITLE): res._chk_error(errors.TITLE_TOO_LONG) res._focus('title') + if recurring != 'never': + try: + Account._byID(c.user._id).email + except: + res._set_error(errors.CANT_RECUR) + res._chk_errors((errors.NO_LOCATION, errors.NO_DESCRIPTION, errors.INVALID_DATE, - errors.NO_DATE)) + errors.NO_DATE, + errors.CANT_RECUR)) if res.error: return + meetup = self.create(c.user._id, title, description, location, latitude, longitude, timestamp, tzoffset, ip, recurring) + + res._redirect(url_for(action='show', id=meetup._id36)) + + @validate(key = nop('key')) + def GET_stopmeetup(self, key): + try: + pj = list(PendingJob._query(PendingJob.c._id == key, data=True))[0] + pj._delete_from_db() + return BoringPage(_("Cancel Meetup"), + content=CancelMeetup()).render() + except: + return BoringPage(_("Page not found"), + content=UnfoundPage()).render() + + def create(self, author_id, title, description, location, latitude, longitude, timestamp, tzoffset, ip, recurring): meetup = Meetup( - author_id = c.user._id, + author_id = author_id, title = title, description = description, @@ -89,22 +136,39 @@ def POST_create(self, res, title, description, location, latitude, longitude, ti meetup._commit() l = Link._submit(meetup_article_title(meetup), meetup_article_text(meetup), - c.user, Subreddit._by_name('discussion'),ip, []) + Account._byID(author_id, data=True), Subreddit._by_name('discussion'),ip, []) l.meetup = meetup._id36 l._commit() meetup.assoc_link = l._id meetup._commit() - when = datetime.now(g.tz) + timedelta(0, 3600) # Leave a short window of time before notification, in case + when = datetime.now(g.tz) + timedelta(0, 60) # Leave a short window of time before notification, in case # the meetup is edited/deleted soon after its creation PendingJob.store(when, 'process_new_meetup', {'meetup_id': meetup._id}) + if recurring != 'never': + if recurring == 'weekly': + offset = 7 + elif recurring == 'biweekly': + offset = 14 + elif recurring == 'monthly': + offset = calculate_month_interval(meetup.datetime()) + + data = {'author_id':author_id,'title':title,'description':description,'location':location, + 'latitude':latitude,'longitude':longitude,'timestamp':timestamp+offset*86400, + 'tzoffset':tzoffset,'ip':ip,'recurring':recurring} + + when = datetime.now(g.tz) + timedelta(offset-15) + + PendingJob.store(when, 'meetup_repost_emailer', data) + + #update the queries if g.write_query_queue: queries.new_link(l) - res._redirect(url_for(action='show', id=meetup._id36)) + return meetup @Json @validate(VUser(), diff --git a/r2/r2/lib/emailer.py b/r2/r2/lib/emailer.py index 7a8e3194..63bbc2ad 100644 --- a/r2/r2/lib/emailer.py +++ b/r2/r2/lib/emailer.py @@ -22,7 +22,7 @@ from email.MIMEText import MIMEText from pylons.i18n import _ from pylons import c, g, request -from r2.lib.pages import PasswordReset, MeetupNotification, Share, Mail_Opt, EmailVerify +from r2.lib.pages import PasswordReset, MeetupNotification, Share, Mail_Opt, EmailVerify, RepostEmail from r2.lib.utils import timeago from r2.models import passhash, Email, Default, has_opted_out from r2.config import cache @@ -56,6 +56,12 @@ def password_email(user): 'lesswrong.com password reset', PasswordReset(user=user, passlink=passlink).render(style='email')) +def repost_email(user, pendingjob): + stoplink = 'http://' + g.domain + '/cancelmeetup/' + str(pendingjob) + simple_email(user.email, 'contact@lesswrong.com', + 'lesswrong.com meetup repost', + RepostEmail(user=user, stoplink=stoplink).render(style='email')) + def confirmation_email(user): simple_email(user.email, 'contact@lesswrong.com', 'lesswrong.com email verification', diff --git a/r2/r2/lib/errors.py b/r2/r2/lib/errors.py index c6868f2b..f18c92cb 100644 --- a/r2/r2/lib/errors.py +++ b/r2/r2/lib/errors.py @@ -85,6 +85,7 @@ ('NOT_ENOUGH_KARMA', _('You do not have enough karma')), ('BAD_POLL_SYNTAX', _('Error in poll syntax')), ('BAD_POLL_BALLOT', _('Error in poll ballot')), + ('CANT_RECUR', _('You need to register an email address to create a recurring meetup')) )) errors = Storage([(e, e) for e in error_list.keys()]) diff --git a/r2/r2/lib/notify.py b/r2/r2/lib/notify.py index 2903b11c..d2c557a6 100644 --- a/r2/r2/lib/notify.py +++ b/r2/r2/lib/notify.py @@ -17,3 +17,6 @@ def get_users_to_notify_for_meetup(coords): def email_user_about_meetup(user, meetup): if meetup.author_id != user._id and user.email: emailer.meetup_email(user=user, meetup=meetup) + +def email_user_about_repost(user, pendingjob): + emailer.repost_email(user=user, pendingjob = pendingjob) diff --git a/r2/r2/lib/pages/pages.py b/r2/r2/lib/pages/pages.py index 64a68168..f69378e1 100644 --- a/r2/r2/lib/pages/pages.py +++ b/r2/r2/lib/pages/pages.py @@ -972,6 +972,11 @@ class EmailVerify(Wrapped): """Form for providing a confirmation code to a new user.""" pass +class RepostEmail(Wrapped): + """Form for notifying a user that their recurring meetup is + about to repost""" + pass + class Captcha(Wrapped): """Container for rendering robot detection device.""" @@ -1607,3 +1612,5 @@ def __init__(self, name, page, skiplayout, **context): space_compress=False, **context) +class CancelMeetup(Wrapped): pass + diff --git a/r2/r2/models/link.py b/r2/r2/models/link.py index c3eb31fd..e0b162f1 100644 --- a/r2/r2/models/link.py +++ b/r2/r2/models/link.py @@ -695,15 +695,20 @@ def _commit(self, *a, **kw): Thing._commit(self, *a, **kw) if should_invalidate: - g.rendercache.delete('side-posts' + '-' + c.site.name) - g.rendercache.delete('side-comments' + '-' + c.site.name) + try: + name = c.site.name + except: + name = Subreddit._byID(self.sr_id).name + + g.rendercache.delete('side-posts' + '-' + name) + g.rendercache.delete('side-comments' + '-' + name) tags = self.tag_names() if 'open_thread' in tags: - g.rendercache.delete('side-open' + '-' + c.site.name) + g.rendercache.delete('side-open' + '-' + name) if 'quotes' in tags: - g.rendercache.delete('side-quote' + '-' + c.site.name) + g.rendercache.delete('side-quote' + '-' + name) if 'group_rationality_diary' in tags: - g.rendercache.delete('side-diary' + '-' + c.site.name) + g.rendercache.delete('side-diary' + '-' + name) # Note that there are no instances of PromotedLink or LinkCompressed, # so overriding their methods here will not change their behaviour diff --git a/r2/r2/models/pending_job.py b/r2/r2/models/pending_job.py index e087cef1..914aef51 100644 --- a/r2/r2/models/pending_job.py +++ b/r2/r2/models/pending_job.py @@ -9,3 +9,4 @@ class PendingJob(Thing): def store(cls, run_at, action, data=None): adjustment = cls(run_at=run_at, action=action, data=data) adjustment._commit() + return adjustment._id diff --git a/r2/r2/templates/cancelmeetup.html b/r2/r2/templates/cancelmeetup.html new file mode 100644 index 00000000..55afd6a5 --- /dev/null +++ b/r2/r2/templates/cancelmeetup.html @@ -0,0 +1,23 @@ +## The contents of this file are subject to the Common Public Attribution +## License Version 1.0. (the "License"); you may not use this file except in +## compliance with the License. You may obtain a copy of the License at +## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public +## License Version 1.1, but Sections 14 and 15 have been added to cover use of +## software over a computer network and provide for limited attribution for the +## Original Developer. In addition, Exhibit A has been modified to be consistent +## with Exhibit B. +## +## Software distributed under the License is distributed on an "AS IS" basis, +## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for +## the specific language governing rights and limitations under the License. +## +## The Original Code is Reddit. +## +## The Original Developer is the Initial Developer. The Initial Developer of +## the Original Code is CondeNet, Inc. +## +## All portions of the code written by CondeNet are Copyright (c) 2006-2008 +## CondeNet, Inc. All Rights Reserved. +################################################################################ + +

Your meetup has been canceled.

diff --git a/r2/r2/templates/editmeetup.html b/r2/r2/templates/editmeetup.html index d8ad62f5..1a9d259e 100644 --- a/r2/r2/templates/editmeetup.html +++ b/r2/r2/templates/editmeetup.html @@ -4,7 +4,7 @@ from routes.util import url_for %> <%namespace name="utils" file="utils.html" import="error_field, submit_form"/> -<%namespace name="form" file="newmeetup.html" import="form_fields"/> +<%namespace name="form" file="meetupform.html" import="form_fields"/>

Edit Meetup

diff --git a/r2/r2/templates/meetupform.html b/r2/r2/templates/meetupform.html new file mode 100644 index 00000000..43d44516 --- /dev/null +++ b/r2/r2/templates/meetupform.html @@ -0,0 +1,46 @@ +<%! + from r2.lib.template_helpers import static + from r2.lib.utils import usformat + from routes.util import url_for +%> +<%namespace name="utils" file="utils.html" import="error_field, submit_form"/> + +<%utils:submit_form action="/meetups/create" onsubmit="return post_form(this, 'meetups/create', null, null, true, '/')" _id="newmeetup" _class="meetup" tzoffset="${thing.tzoffset}" latitude="${thing.latitude}" longitude="${thing.longitude}"> +

New Meetup

+ ${form_fields()} + + + + +<%def name="form_fields()"> +
+ + + ${error_field("NO_TITLE", "span")} + ${error_field("TITLE_TOO_LONG", "span")} +
+ +
+ + + ${error_field("NO_LOCATION", "p", cls="form-info-line")} +
+ +
+ + + ${error_field("NO_DESCRIPTION", "span")} +
+ +
+ + + ${error_field("NO_DATE", "span")} + ${error_field("INVALID_DATE", "span")} +
+ + + + diff --git a/r2/r2/templates/newmeetup.html b/r2/r2/templates/newmeetup.html index 43d44516..6fa6a924 100644 --- a/r2/r2/templates/newmeetup.html +++ b/r2/r2/templates/newmeetup.html @@ -41,6 +41,17 @@

New Meetup

${error_field("INVALID_DATE", "span")} +
+ + + ${error_field("CANT_RECUR", "span")} +
+ diff --git a/r2/r2/templates/repostemail.email b/r2/r2/templates/repostemail.email new file mode 100644 index 00000000..204f10dc --- /dev/null +++ b/r2/r2/templates/repostemail.email @@ -0,0 +1,29 @@ +## The contents of this file are subject to the Common Public Attribution +## License Version 1.0. (the "License"); you may not use this file except in +## compliance with the License. You may obtain a copy of the License at +## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public +## License Version 1.1, but Sections 14 and 15 have been added to cover use of +## software over a computer network and provide for limited attribution for the +## Original Developer. In addition, Exhibit A has been modified to be consistent +## with Exhibit B. +## +## Software distributed under the License is distributed on an "AS IS" basis, +## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for +## the specific language governing rights and limitations under the License. +## +## The Original Code is Reddit. +## +## The Original Developer is the Initial Developer. The Initial Developer of +## the Original Code is CondeNet, Inc. +## +## All portions of the code written by CondeNet are Copyright (c) 2006-2008 +## CondeNet, Inc. All Rights Reserved. +################################################################################ + +Hello ${thing.user.name}: + +Your recurring meetup is about to repost, to take place 2 weeks from now. + +If you would like to cancel your recurring meetup please go to: ${thing.stoplink}. + +Thank you for hosting meetups! diff --git a/scripts/run_pending_jobs.py b/scripts/run_pending_jobs.py index 63e1963b..942417fe 100644 --- a/scripts/run_pending_jobs.py +++ b/scripts/run_pending_jobs.py @@ -9,7 +9,7 @@ """ from sys import stderr -from datetime import datetime +from datetime import datetime, timedelta from pylons import g @@ -17,6 +17,7 @@ from r2.lib import notify from r2.lib.db.thing import NotFound from r2.models import Account, Meetup, PendingJob +from r2.controllers.meetupscontroller import MeetupsController class JobProcessor: @@ -76,6 +77,22 @@ def job_send_meetup_email_to_user(meetup_id, username): # Users can get deleted so ignore if not found pass +def job_repost_meetup(author_id, title, description, location, latitude, longitude, timestamp, tzoffset, ip, recurring): + mc = MeetupsController() + mc.create(author_id, title, description, location, latitude, longitude, timestamp, tzoffset, ip, recurring) + +def job_meetup_repost_emailer(author_id, title, description, location, latitude, longitude, timestamp, tzoffset, ip, recurring): + when = datetime.now(g.tz) + timedelta(1) + data = {'author_id':author_id,'title':title,'description':description,'location':location, + 'latitude':latitude,'longitude':longitude,'timestamp':timestamp, + 'tzoffset':tzoffset,'ip':ip,'recurring':recurring} + + pj = PendingJob.store(when, 'repost_meetup', data) + try: + user = Account._byID(author_id) + notify.email_user_about_repost(user, pj) + except NotFound: + pass try: JobProcessor().run()