Skip to content
This repository has been archived by the owner on Apr 17, 2018. It is now read-only.

Recurring meetup #91

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions r2/r2/config/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down
84 changes: 74 additions & 10 deletions r2/r2/controllers/meetupscontroller.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from datetime import datetime, timedelta
import json
import calendar

from mako.template import Template
from pylons.i18n import _
Expand All @@ -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


Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like having to write and maintain date/time code if I don't have to: it's always really fiddly and yuck. I feel like this sort of thing should exist in a library somewhere - maybe dateutil?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))
Expand Down Expand Up @@ -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'))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if you defined a Validator that ensures that the value of recurring is actually something that made sense.

I would make a class called VRecurring (in this file because I don't think you're using it elsewhere), make it inherit from VOneOf (see r2/r2/controllers/validator/validator.py) and in __init__ you can pass up the valid options.

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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't work, because the final version of the wiki-account-creation code set a default email of None for all accounts. Test if recurring != 'never' and c.user.email is None:.

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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should at least be a POST. A GET should never do anything other than retrieval. See HTTP, sec 9.1.1 (safe methods). Suppose that an email client pre-emptively fetches links. The meetup will self-destruct. In addition, the endpoint in its current form will let anyone delete any PendingJob ever.

Can you also add additional checking? Ideally only the user who created the meetup (or an admin) should be able to stop the recurrence.

Is there a page somewhere that lists a user's upcoming meetups? That would be a good place to put a "cancel recurring meetup" button, and the "your meetup is about to be reposted" email can just link to that page.

try:
pj = list(PendingJob._query(PendingJob.c._id == key, data=True))[0]
pj._delete_from_db()
return BoringPage(_("Cancel Meetup"),
content=CancelMeetup()).render()
except:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I worry about the catch-all except clause. What exceptions have you been seeing that you need to catch? I don't want to see some future error silently swallowed by this sort of thing.

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,
Expand All @@ -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(),
Expand Down
8 changes: 7 additions & 1 deletion r2/r2/lib/emailer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, '[email protected]',
'lesswrong.com meetup repost',
RepostEmail(user=user, stoplink=stoplink).render(style='email'))

def confirmation_email(user):
simple_email(user.email, '[email protected]',
'lesswrong.com email verification',
Expand Down
1 change: 1 addition & 0 deletions r2/r2/lib/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We seem to be adding a lot of features that need the user to have a verified email. I think ('EMAIL_REQUIRED', _('You need to register an email address before you can do that.')) might be a better name for this error. CANT_RECUR sounds like a general failure concept but the message is quite specific.

errors = Storage([(e, e) for e in error_list.keys()])

Expand Down
3 changes: 3 additions & 0 deletions r2/r2/lib/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
7 changes: 7 additions & 0 deletions r2/r2/lib/pages/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -1607,3 +1612,5 @@ def __init__(self, name, page, skiplayout, **context):
space_compress=False,
**context)

class CancelMeetup(Wrapped): pass

15 changes: 10 additions & 5 deletions r2/r2/models/link.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please investigate here also and find out what type of exception you're expecting to catch.


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
Expand Down
1 change: 1 addition & 0 deletions r2/r2/models/pending_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 23 additions & 0 deletions r2/r2/templates/cancelmeetup.html
Original file line number Diff line number Diff line change
@@ -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.
################################################################################

<h2>Your meetup has been canceled.<h2>
2 changes: 1 addition & 1 deletion r2/r2/templates/editmeetup.html
Original file line number Diff line number Diff line change
Expand Up @@ -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"/>

<h1>Edit Meetup</h1>

Expand Down
46 changes: 46 additions & 0 deletions r2/r2/templates/meetupform.html
Original file line number Diff line number Diff line change
@@ -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}">
<h1>New Meetup</h1>
${form_fields()}
</%utils:submit_form>

<script src="${static('meetups.js')}" type="text/javascript" charset="utf-8"></script>

<%def name="form_fields()">
<fieldset>
<label for="title">${_("Title")}:</label>
<input id="title" name="title" value="${thing.title}" onfocus="clearTitle(this)" type="text" />
${error_field("NO_TITLE", "span")}
${error_field("TITLE_TOO_LONG", "span")}
</fieldset>

<fieldset>
<label for="location">${_("Location")}:</label>
<input id="location" name="location" value="${thing.location}" type="text" />
${error_field("NO_LOCATION", "p", cls="form-info-line")}
</fieldset>

<fieldset>
<label for="description">${_("Description")}:</label>
<textarea id="description" name="description" rows="5" cols="35">
${thing.description}
</textarea>
${error_field("NO_DESCRIPTION", "span")}
</fieldset>

<fieldset>
<label for="date">${_("Date")}:</label>
<input id="date" class="date" name="timestamp" value="${thing.timestamp}" type="text" />
${error_field("NO_DATE", "span")}
${error_field("INVALID_DATE", "span")}
</fieldset>

<button class="btn" type="submit">${_("Submit Meetup")}</button>
<span id="status" class="error"></span>
</%def>
11 changes: 11 additions & 0 deletions r2/r2/templates/newmeetup.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ <h1>New Meetup</h1>
${error_field("INVALID_DATE", "span")}
</fieldset>

<fieldset>
<label for="recurring">${_("Recurs")}</label>
<select id="recurring" name="recurring">
<option value="never">Never</option>
<option value="weekly">Weekly</option>
<option value="biweekly">Biweekly</option>
<option value="monthly">Monthly</option>
</select>
${error_field("CANT_RECUR", "span")}
</fieldset>

<button class="btn" type="submit">${_("Submit Meetup")}</button>
<span id="status" class="error"></span>
</%def>
29 changes: 29 additions & 0 deletions r2/r2/templates/repostemail.email
Original file line number Diff line number Diff line change
@@ -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!
Loading