summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMagnus Hagander2019-08-21 14:14:11 +0000
committerMagnus Hagander2019-08-21 15:11:22 +0000
commitc2451c4eacf222ab4e3f93d845eef1cbf06618a2 (patch)
tree11f4de28fc7311080d8cd6e4eca30107808cccf2
parente2a304163b25c221923dacee8165b8efdcb06916 (diff)
Major overhaul of Twitter integration
* Add backend editor for queued conference tweets. This way manual tweets can be added, and auto-generated ones edited. * Don't delete tweets when posted, just mark them as sent. This together with the fact that the backend editors adds a field of who created it gives an audit trail for posts. * Support posting images in tweets. * Support twitter campaigns * Generic support for multiple campaigns, which are about generating a set of tweets based on other data in the database. * Specifically add support for "session campaigns", which generates tweets to market approved sessions. * More campaign types can be added in the future. * Each campaign gets to define a jinja2 template that is rendered across the objects to create tweets, which are then spread out across time. * Breakout the "post conference news as tweets" into it's own managed job, to keep the separation clear. This job now just adds entries to the queue of tweets.
-rw-r--r--docs/confreg/index.md1
-rw-r--r--docs/confreg/integrations.md59
-rw-r--r--postgresqleu/confreg/backendforms.py70
-rw-r--r--postgresqleu/confreg/backendviews.py77
-rw-r--r--postgresqleu/confreg/campaigns.py141
-rw-r--r--postgresqleu/confreg/management/commands/confreg_post_news.py43
-rw-r--r--postgresqleu/confreg/migrations/0057_twitter_enhancement.py49
-rw-r--r--postgresqleu/confreg/models.py14
-rw-r--r--postgresqleu/confreg/views.py4
-rw-r--r--postgresqleu/confsponsor/views.py2
-rw-r--r--postgresqleu/newsevents/management/commands/twitter_post.py43
-rw-r--r--postgresqleu/urls.py3
-rw-r--r--postgresqleu/util/docsviews.py2
-rw-r--r--postgresqleu/util/messaging/twitter.py23
-rw-r--r--template/confreg/admin_dashboard_single.html4
-rw-r--r--template/confreg/admin_integ_twitter.html3
16 files changed, 497 insertions, 41 deletions
diff --git a/docs/confreg/index.md b/docs/confreg/index.md
index dda8d400..92ded8f7 100644
--- a/docs/confreg/index.md
+++ b/docs/confreg/index.md
@@ -41,6 +41,7 @@ work from here.
* [Access tokens](tokens)
* [Badges](badges)
* [Integrations](integrations)
+ * [Campaigns](integrations#campaigns)
* [Copying from another conference](copyfromother)
* [Skinning the conference pages](skinning)
diff --git a/docs/confreg/integrations.md b/docs/confreg/integrations.md
index 54564740..49b824f6 100644
--- a/docs/confreg/integrations.md
+++ b/docs/confreg/integrations.md
@@ -7,10 +7,22 @@ are currently available:
The twitter integration supports:
+* Manually posting to conference twitter
* Posting conference news
* Posting confirmed sponsorship benefits
+* Creating campaigns
* Sending reminders to speaker just before their presentation
+
+### Manually posting to conference twitter
+
+To manually post to twitter, just add an entry to the *Twitter post
+queue*. This button becomes available from the main dashboard of a
+conference once the integration has been configured.
+
+Only posts which are flagged as *approved* will be posted. All other
+posts are queued until they are approved.
+
### Posting conference news
Conference news is posted when generated. It will contain just the
@@ -54,3 +66,50 @@ application can be authorized (make sure you are logged in with the
*correct* Twitter account at this point!). Once authorized, a PIN code
is shown, which should be copied and pasted into the form on the
original page.
+
+For each conference a time period start and end can be configured. No
+tweets will be posted outside of this time. Any tweets posted during
+that time, manual or automatic, will be queued up and sent the next
+day once the time period is entered.
+
+## Campaigns <a name="campaigns"></a>
+
+Automated campaigns of tweets can be created. When a campaign is
+created, a number of tweets are automatically created and added to the
+queue, at a defined interval (including some random portion). The
+contents of the tweets are based on data in the database.
+
+All tweets are added as not approved, and thus have to be approved
+before sending. This also allows the operator to edit the tweets and
+possibly change some of the text to be more specific.
+
+A full set of jinja operations are available in the campaigns, and
+it's possible to do things like define macros, which can make for
+fairly advanced templates.
+
+There can be multiple types of campaigns:
+
+### Approved sessions campaign
+
+This creates a campaign with one tweet for each approved session in
+the system, filtered by which track the session is on. The template is
+called with the following variables:
+
+session
+: An object referencing this session. It has access to the same
+variables as a general template, so it can access for example the
+speakers information.
+
+conference
+: The current conference object.
+
+#### Sample template
+
+A sample template for this campaign that shows some variables used:
+
+~~~
+{%macro speaker(s)%}{{s}}{%if s.twittername%} ({{s.twittername}}){%endif%}{%endmacro%}
+Come see {{session.speaker.all()|map("applymacro", "speaker")|join(" and ")}} talk about {{session.title}}
+
+#awesome #conference #pgeu
+~~~
diff --git a/postgresqleu/confreg/backendforms.py b/postgresqleu/confreg/backendforms.py
index 9dcc848f..64bd3f51 100644
--- a/postgresqleu/confreg/backendforms.py
+++ b/postgresqleu/confreg/backendforms.py
@@ -13,8 +13,9 @@ from collections import OrderedDict
from psycopg2.extras import DateTimeTZRange
from decimal import Decimal
+from postgresqleu.util.db import exec_to_single_list
from postgresqleu.util.forms import ConcurrentProtectedModelForm
-from postgresqleu.util.widgets import StaticTextWidget, EmailTextWidget, PhotoUploadWidget
+from postgresqleu.util.widgets import StaticTextWidget, EmailTextWidget, PhotoUploadWidget, MonospaceTextarea
from postgresqleu.util.random import generate_random_token
from postgresqleu.util.backendforms import BackendForm
@@ -28,6 +29,7 @@ from postgresqleu.confreg.models import ConferenceSessionScheduleSlot, Volunteer
from postgresqleu.confreg.models import DiscountCode, AccessToken, AccessTokenPermissions
from postgresqleu.confreg.models import ConferenceSeries
from postgresqleu.confreg.models import ConferenceNews
+from postgresqleu.confreg.models import ConferenceTweetQueue
from postgresqleu.confreg.models import ShirtSize
from postgresqleu.confreg.models import RefundPattern
from postgresqleu.newsevents.models import NewsPosterProfile
@@ -38,6 +40,8 @@ from postgresqleu.confreg.models import STATUS_CHOICES
from postgresqleu.util.backendlookups import GeneralAccountLookup, CountryLookup
from postgresqleu.confreg.backendlookups import RegisteredUsersLookup, SpeakerLookup, SessionTagLookup
+from postgresqleu.confreg.campaigns import allcampaigns
+
class BackendConferenceForm(BackendForm):
helplink = 'configuring#conferenceform'
@@ -901,6 +905,70 @@ class TwitterTestForm(django.forms.Form):
message = django.forms.CharField(max_length=200)
+class BackendTweetQueueForm(BackendForm):
+ helplink = 'integrations#twitter'
+ list_fields = ['datetime', 'contents', 'author', 'approved', 'sent', 'hasimage', ]
+ verbose_field_names = {
+ 'hasimage': 'Has image',
+ }
+ exclude_date_validators = ['datetime', ]
+ defaultsort = [['sent', 'asc'], ['datetime', 'desc']]
+ exclude_fields_from_validation = ['image', ]
+ queryset_select_related = ['author', ]
+ queryset_extra_fields = {
+ 'hasimage': "image is not null and image != ''",
+ }
+
+ class Meta:
+ model = ConferenceTweetQueue
+ fields = ['datetime', 'approved', 'contents', 'image']
+ widgets = {
+ 'contents': MonospaceTextarea,
+ }
+
+ def clean_datetime(self):
+ if self.instance:
+ t = self.cleaned_data['datetime'].time()
+ if self.conference.twitter_timewindow_start and self.conference.twitter_timewindow_start != datetime.time(0, 0, 0):
+ if t < self.conference.twitter_timewindow_start:
+ raise ValidationError("Tweets for this conference cannot be scheduled before {}".format(self.conference.twitter_timewindow_start))
+ if self.conference.twitter_timewindow_end:
+ if t > self.conference.twitter_timewindow_end and self.conference.twitter_timewindow_end != datetime.time(0, 0, 0):
+ raise ValidationError("Tweets for this conference cannot be scheduled after {}".format(self.conference.twitter_timewindow_end))
+ return self.cleaned_data['datetime']
+
+ @classmethod
+ def get_assignable_columns(cls, conference):
+ return [
+ {
+ 'name': 'approved',
+ 'title': 'Approval',
+ 'options': [(1, 'Yes'), (0, 'No'), ]
+ },
+ ]
+
+ @classmethod
+ def get_rowclass(self, obj):
+ if obj.sent:
+ return "info"
+ return None
+
+ @classmethod
+ def get_column_filters(cls, conference):
+ return {
+ 'Author': exec_to_single_list('SELECT DISTINCT username FROM confreg_conferencetweetqueue q INNER JOIN auth_user u ON u.id=q.author_id WHERE q.conference_id=%(confid)s', {'confid': conference.id, }),
+ 'Approved': ['true', 'false'],
+ 'Sent': ['true', 'false'],
+ }
+
+
+class TweetCampaignSelectForm(django.forms.Form):
+ campaigntype = django.forms.ChoiceField(
+ label='Campaign type',
+ choices=[(id, c.name) for id, c in allcampaigns],
+ )
+
+
#
# Form for confirming a registration
#
diff --git a/postgresqleu/confreg/backendviews.py b/postgresqleu/confreg/backendviews.py
index ed1141a2..24c13c67 100644
--- a/postgresqleu/confreg/backendviews.py
+++ b/postgresqleu/confreg/backendviews.py
@@ -27,6 +27,7 @@ from .models import BulkPayment
from .models import AccessToken
from .models import ShirtSize
from .models import PendingAdditionalOrder
+from .models import ConferenceTweetQueue
from postgresqleu.invoices.models import Invoice
from postgresqleu.confsponsor.util import get_sponsor_dashboard_data
@@ -42,10 +43,13 @@ from .backendforms import BackendAccessTokenForm
from .backendforms import BackendConferenceSeriesForm
from .backendforms import BackendTshirtSizeForm
from .backendforms import BackendNewsForm
-from .backendforms import TwitterForm, TwitterTestForm
+from .backendforms import TwitterForm, TwitterTestForm, BackendTweetQueueForm
+from .backendforms import TweetCampaignSelectForm
from .backendforms import BackendSendEmailForm
from .backendforms import BackendRefundPatternForm
+from .campaigns import get_campaign_from_id
+
#######################
# Simple editing views
@@ -243,6 +247,18 @@ def edit_news(request, urlname, rest):
rest)
+def edit_tweetqueue(request, urlname, rest):
+ conference = get_authenticated_conference(request, urlname)
+
+ return backend_list_editor(request,
+ urlname,
+ BackendTweetQueueForm,
+ rest,
+ return_url='../../',
+ instancemaker=lambda: ConferenceTweetQueue(conference=conference, author=request.user)
+ )
+
+
###
# Non-simple-editor views
###
@@ -354,10 +370,18 @@ def twitter_integration(request, urlname):
conference.twitter_token = tokens.get('oauth_token')
conference.twitter_secret = tokens.get('oauth_token_secret')
conference.twittersync_active = False
+ tw = Twitter(conference)
+ try:
+ conference.twitter_user = tw.get_own_screen_name()
+ except Exception as e:
+ messages.error(request, 'Failed to verify account credentials and get username: {}'.format(e))
+ return HttpResponseRedirect('.')
+
conference.save()
messages.info(request, 'Twitter integration enabled')
return HttpResponseRedirect('.')
elif request.POST.get('deactivate_twitter', '') == '1':
+ conference.twitter_user = ''
conference.twitter_token = ''
conference.twitter_secret = ''
conference.twittersync_active = False
@@ -397,6 +421,57 @@ def twitter_integration(request, urlname):
})
+def tweetcampaignselect(request, urlname):
+ conference = get_authenticated_conference(request, urlname)
+
+ if request.method == 'POST':
+ form = TweetCampaignSelectForm(data=request.POST)
+ if form.is_valid():
+ return HttpResponseRedirect("{}/".format(form.cleaned_data['campaigntype']))
+ else:
+ form = TweetCampaignSelectForm()
+
+ return render(request, 'confreg/admin_backend_form.html', {
+ 'conference': conference,
+ 'basetemplate': 'confreg/confadmin_base.html',
+ 'form': form,
+ 'whatverb': 'Create campaign',
+ 'savebutton': 'Select campaign type',
+ 'cancelurl': '../../',
+ 'helplink': 'integrations#campaigns',
+ })
+
+
+def tweetcampaign(request, urlname, typeid):
+ conference = get_authenticated_conference(request, urlname)
+
+ campaign = get_campaign_from_id(typeid)
+
+ if request.method == 'GET' and 'fieldpreview' in request.GET:
+ return campaign.get_dynamic_preview(conference, request.GET['fieldpreview'], request.GET['previewval'])
+
+ if request.method == 'POST':
+ form = campaign.form(conference, request.POST)
+ if form.is_valid():
+ with transaction.atomic():
+ form.generate_tweets(request.user)
+ messages.info(request, "Campaign tweets generated")
+ return HttpResponseRedirect("../../queue/")
+ else:
+ form = campaign.form(conference)
+
+ return render(request, 'confreg/admin_backend_form.html', {
+ 'conference': conference,
+ 'basetemplate': 'confreg/confadmin_base.html',
+ 'form': form,
+ 'whatverb': 'Create campaign',
+ 'savebutton': "Create campaign",
+ 'cancelurl': '../../../',
+ 'note': campaign.note,
+ 'helplink': 'integrations#campaigns',
+ })
+
+
class DelimitedWriter(object):
def __init__(self, delimiter):
self.delimiter = delimiter
diff --git a/postgresqleu/confreg/campaigns.py b/postgresqleu/confreg/campaigns.py
new file mode 100644
index 00000000..ecb1d004
--- /dev/null
+++ b/postgresqleu/confreg/campaigns.py
@@ -0,0 +1,141 @@
+from django import forms
+from django.core.exceptions import ValidationError
+from django.http import Http404, HttpResponse
+from django.utils.dateparse import parse_datetime, parse_duration
+from postgresqleu.confreg.jinjafunc import JinjaTemplateValidator, render_sandboxed_template
+
+from postgresqleu.util.widgets import MonospaceTextarea
+from postgresqleu.confreg.models import ConferenceSession, ConferenceTweetQueue, Track
+
+import datetime
+import random
+
+
+def _timestamps_for_tweets(conference, starttime, interval, randint, num):
+ if isinstance(starttime, datetime.datetime):
+ t = starttime
+ else:
+ t = parse_datetime(starttime)
+
+ if isinstance(interval, datetime.time):
+ ival = datetime.timedelta(interval.hour, interval.minute, interval.second)
+ else:
+ ival = parse_duration(interval)
+
+ if isinstance(randint, datetime.time):
+ rsec = datetime.timedelta(interval.hour, interval.minute, interval.second).total_seconds()
+ else:
+ rsec = parse_duration(randint).total_seconds()
+
+ for i in range(num):
+ yield t
+ t += ival
+ t += datetime.timedelta(seconds=rsec * random.random())
+ if t.time() > conference.twitter_timewindow_end:
+ t = datetime.datetime.combine(t.date() + datetime.timedelta(days=1), conference.twitter_timewindow_start)
+
+
+class BaseCampaignForm(forms.Form):
+ starttime = forms.DateTimeField(label="Date and time of first tweet", initial=datetime.datetime.now)
+ timebetween = forms.TimeField(label="Time between tweets", initial=datetime.time(1, 0, 0))
+ timerandom = forms.TimeField(label="Time randomization", initial=datetime.time(0, 10, 0),
+ help_text="A random time from zero to this is added after each time interval")
+ content_template = forms.CharField(max_length=250,
+ widget=MonospaceTextarea,
+ required=True)
+ dynamic_preview_fields = ['content_template', ]
+
+ confirm = forms.BooleanField(help_text="Confirm that you want to generate all the tweets for this campaign at this time", required=False)
+
+ def __init__(self, conference, *args, **kwargs):
+ self.conference = conference
+ self.field_order = ['starttime', 'timebetween', 'timerandom', 'content_template'] + self.custom_fields + ['confirm', ]
+
+ super(BaseCampaignForm, self).__init__(*args, *kwargs)
+
+ if not all([self.data.get(f) for f in ['starttime', 'timebetween', 'timerandom', 'content_template'] + self.custom_fields]):
+ del self.fields['confirm']
+ else:
+ num = self.get_queryset().count()
+ tsl = list(_timestamps_for_tweets(conference,
+ self.data.get('starttime'),
+ self.data.get('timebetween'),
+ self.data.get('timerandom'),
+ num,
+ ))
+ if tsl:
+ approxend = tsl[-1]
+ self.fields['confirm'].help_text = "Confirm that you want to generate all the tweets for this campaign at this time. Campaign will go on until approximately {}, with {} posts.".format(approxend, num)
+ else:
+ self.fields['confirm'].help_text = "Campaign matches no entries. Try again."
+
+ def clean_confirm(self):
+ if not self.cleaned_data['confirm']:
+ if self.get_queryset().count == 0:
+ del self.fields['confirm']
+ else:
+ raise ValidationError("Please check thix box to confirm that you want to generate all tweets!")
+
+ def clean(self):
+ if self.get_queryset().count() == 0:
+ self.add_error(None, 'Current filters return no entries. Fix your filters and try again!')
+ del self.fields['confirm']
+ return self.cleaned_data
+
+
+class ApprovedSessionsCampaignForm(BaseCampaignForm):
+ tracks = forms.ModelMultipleChoiceField(required=True, queryset=Track.objects.all())
+
+ custom_fields = ['tracks', ]
+
+ def __init__(self, *args, **kwargs):
+ super(ApprovedSessionsCampaignForm, self).__init__(*args, **kwargs)
+ self.fields['tracks'].queryset = Track.objects.filter(conference=self.conference)
+
+ @classmethod
+ def generate_tweet(cls, conference, session, s):
+ return render_sandboxed_template(s, {
+ 'conference': conference,
+ 'session': session,
+ }).strip()
+
+ def get_queryset(self):
+ return ConferenceSession.objects.filter(conference=self.conference, status=1, cross_schedule=False, track__in=self.data.getlist('tracks'))
+
+ def generate_tweets(self, author):
+ sessions = list(self.get_queryset())
+ for ts, session in zip(_timestamps_for_tweets(self.conference, self.cleaned_data['starttime'], self.cleaned_data['timebetween'], self.cleaned_data['timerandom'], len(sessions)), sessions):
+ ConferenceTweetQueue(
+ conference=self.conference,
+ datetime=ts,
+ contents=self.generate_tweet(self.conference, session, self.cleaned_data['content_template']),
+ approved=False,
+ author=author,
+ ).save()
+
+
+class ApprovedSessionsCampaign(object):
+ name = "Approved sessions campaign"
+ form = ApprovedSessionsCampaignForm
+ note = "This campaign will create one tweet for each approved session in the system."
+
+ @classmethod
+ def get_dynamic_preview(self, conference, fieldname, s):
+ if fieldname == 'content_template':
+ # Generate a preview of 3 (an arbitrary number) sessions
+ return HttpResponse("\n\n-------------------------------\n\n".join([
+ self.form.generate_tweet(conference, session, s)
+ for session in ConferenceSession.objects.filter(conference=conference, status=1, cross_schedule=False)[:3]
+ ]), content_type='text/plain')
+
+
+allcampaigns = (
+ (1, ApprovedSessionsCampaign),
+)
+
+
+def get_campaign_from_id(id):
+ for i, c in allcampaigns:
+ if i == int(id):
+ return c
+ raise Http404()
diff --git a/postgresqleu/confreg/management/commands/confreg_post_news.py b/postgresqleu/confreg/management/commands/confreg_post_news.py
new file mode 100644
index 00000000..b74304a4
--- /dev/null
+++ b/postgresqleu/confreg/management/commands/confreg_post_news.py
@@ -0,0 +1,43 @@
+#
+# Post tweets about news.
+#
+# This doesn't actually post the tweets -- it just places them in the
+# outbound queue for the global twitter posting script to handle.
+#
+
+# Copyright (C) 2019, PostgreSQL Europe
+
+from django.core.management.base import BaseCommand
+from django.db import transaction
+
+from datetime import datetime, timedelta
+
+from postgresqleu.confreg.models import ConferenceNews, ConferenceTweetQueue
+
+
+class Command(BaseCommand):
+ help = 'Schedule tweets about conference news'
+
+ class ScheduledJob:
+ scheduled_interval = timedelta(minutes=10)
+ internal = True
+
+ @classmethod
+ def should_run(self):
+ # Any untweeted news from a conference with twitter active where the news is dated in the past (so that it
+ # is actually visible), but not more than 7 days in the past (in which case we skip it).
+ return ConferenceNews.objects.filter(tweeted=False, conference__twittersync_active=True, datetime__lt=datetime.now(), datetime__gt=datetime.now() - timedelta(days=7)).exists()
+
+ @transaction.atomic
+ def handle(self, *args, **options):
+ for n in ConferenceNews.objects.filter(tweeted=False, conference__twittersync_active=True, datetime__lt=datetime.now(), datetime__gt=datetime.now() - timedelta(days=7)):
+ statusstr = "{0} {1}##{2}".format(n.title[:250 - 40],
+ n.conference.confurl,
+ n.id)
+ ConferenceTweetQueue(
+ conference=n.conference,
+ contents=statusstr,
+ approved=True,
+ ).save()
+ n.tweeted = True
+ n.save()
diff --git a/postgresqleu/confreg/migrations/0057_twitter_enhancement.py b/postgresqleu/confreg/migrations/0057_twitter_enhancement.py
new file mode 100644
index 00000000..08c0031b
--- /dev/null
+++ b/postgresqleu/confreg/migrations/0057_twitter_enhancement.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2019-08-21 16:24
+from __future__ import unicode_literals
+
+import datetime
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import postgresqleu.util.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('confreg', '0056_track_speakerreg'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='conferencetweetqueue',
+ options={'ordering': ['sent', 'datetime'], 'verbose_name': 'Conference Tweet', 'verbose_name_plural': 'Conference Tweets'},
+ ),
+ migrations.AddField(
+ model_name='conferencetweetqueue',
+ name='approved',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name='conferencetweetqueue',
+ name='author',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
+ ),
+ migrations.AddField(
+ model_name='conferencetweetqueue',
+ name='image',
+ field=postgresqleu.util.fields.ImageBinaryField(blank=True, max_length=1000000, null=True),
+ ),
+ migrations.AddField(
+ model_name='conferencetweetqueue',
+ name='sent',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AlterField(
+ model_name='conferencetweetqueue',
+ name='datetime',
+ field=models.DateTimeField(default=datetime.datetime.now, help_text='Date and time to send tweet', verbose_name='Date and time'),
+ ),
+ ]
diff --git a/postgresqleu/confreg/models.py b/postgresqleu/confreg/models.py
index 1a782d0d..dbf3fbc4 100644
--- a/postgresqleu/confreg/models.py
+++ b/postgresqleu/confreg/models.py
@@ -16,7 +16,7 @@ from postgresqleu.util.validators import validate_lowercase, validate_urlname
from postgresqleu.util.validators import TwitterValidator
from postgresqleu.util.validators import PictureUrlValidator, ImageValidator
from postgresqleu.util.forms import ChoiceArrayField
-from postgresqleu.util.fields import LowercaseEmailField
+from postgresqleu.util.fields import LowercaseEmailField, ImageBinaryField
from postgresqleu.confreg.dbimage import SpeakerImageStorage
@@ -1211,5 +1211,15 @@ class ConferenceNews(models.Model):
class ConferenceTweetQueue(models.Model):
conference = models.ForeignKey(Conference, null=False, on_delete=models.CASCADE)
- datetime = models.DateTimeField(blank=False, default=datetime.datetime.now)
+ datetime = models.DateTimeField(blank=False, default=datetime.datetime.now, verbose_name="Date and time",
+ help_text="Date and time to send tweet")
contents = models.CharField(max_length=250, null=False, blank=False)
+ image = ImageBinaryField(null=True, blank=True, max_length=1000000)
+ approved = models.BooleanField(null=False, default=False, blank=False)
+ author = models.ForeignKey(User, null=True, blank=True)
+ sent = models.BooleanField(null=False, default=False, blank=False)
+
+ class Meta:
+ ordering = ['sent', 'datetime', ]
+ verbose_name_plural = 'Conference Tweets'
+ verbose_name = 'Conference Tweet'
diff --git a/postgresqleu/confreg/views.py b/postgresqleu/confreg/views.py
index 0774ae01..75a1af79 100644
--- a/postgresqleu/confreg/views.py
+++ b/postgresqleu/confreg/views.py
@@ -25,7 +25,7 @@ from .models import AttendeeMail, ConferenceAdditionalOption
from .models import PendingAdditionalOrder
from .models import RegistrationWaitlistEntry, RegistrationWaitlistHistory
from .models import STATUS_CHOICES
-from .models import ConferenceNews
+from .models import ConferenceNews, ConferenceTweetQueue
from .forms import ConferenceRegistrationForm, RegistrationChangeForm, ConferenceSessionFeedbackForm
from .forms import ConferenceFeedbackForm, SpeakerProfileForm
from .forms import CallForPapersForm
@@ -2747,6 +2747,8 @@ def admin_dashboard_single(request, urlname):
'uncheckedin_attendees': conditional_exec_to_scalar(conference.checkinactive, "SELECT EXISTS (SELECT 1 FROM confreg_conferenceregistration r WHERE r.conference_id=%(confid)s AND payconfirmedat IS NOT NULL AND checkedinat IS NULL)", {'confid': conference.id}),
'pending_sponsors': conditional_exec_to_scalar(conference.callforsponsorsopen, "SELECT EXISTS (SELECT 1 FROM confsponsor_sponsor WHERE conference_id=%(confid)s AND invoice_id IS NULL AND NOT confirmed)", {'confid': conference.id}),
'pending_sponsor_benefits': exec_to_scalar("SELECT EXISTS (SELECT 1 FROM confsponsor_sponsorclaimedbenefit b INNER JOIN confsponsor_sponsor s ON s.id=b.sponsor_id WHERE s.conference_id=%(confid)s AND NOT (b.confirmed OR b.declined))", {'confid': conference.id}),
+ 'pending_tweets': ConferenceTweetQueue.objects.filter(conference=conference, sent=False).exists(),
+ 'pending_tweet_approvals': ConferenceTweetQueue.objects.filter(conference=conference, approved=False).exists(),
})
diff --git a/postgresqleu/confsponsor/views.py b/postgresqleu/confsponsor/views.py
index 24934466..6152b1b8 100644
--- a/postgresqleu/confsponsor/views.py
+++ b/postgresqleu/confsponsor/views.py
@@ -798,7 +798,7 @@ def _confirm_benefit(request, benefit):
# Potentially send tweet
if benefit.benefit.tweet_template:
- ConferenceTweetQueue(conference=conference, datetime=datetime.now(),
+ ConferenceTweetQueue(conference=conference, datetime=datetime.now(), approved=True,
contents=render_sandboxed_template(benefit.benefit.tweet_template, {
'benefit': benefit.benefit,
'level': benefit.benefit.level,
diff --git a/postgresqleu/newsevents/management/commands/twitter_post.py b/postgresqleu/newsevents/management/commands/twitter_post.py
index 523d3dfd..fc9f842a 100644
--- a/postgresqleu/newsevents/management/commands/twitter_post.py
+++ b/postgresqleu/newsevents/management/commands/twitter_post.py
@@ -26,7 +26,7 @@ def conferences_with_tweets_queryset():
return Conference.objects.filter(twittersync_active=True,
twitter_timewindow_start__lt=n,
twitter_timewindow_end__gt=n).extra(where=[
- "EXISTS (SELECT 1 FROM confreg_conferencenews n WHERE n.conference_id=confreg_conference.id AND (NOT tweeted) AND datetime > now()-'7 days'::interval AND datetime < now()) OR EXISTS (SELECT 1 FROM confreg_conferencetweetqueue q WHERE q.conference_id=confreg_conference.id)"
+ "EXISTS (SELECT 1 FROM confreg_conferencetweetqueue q WHERE q.conference_id=confreg_conference.id AND q.approved AND NOT q.sent)"
])
@@ -34,7 +34,7 @@ class Command(BaseCommand):
help = 'Post to twitter'
class ScheduledJob:
- scheduled_interval = timedelta(minutes=10)
+ scheduled_interval = timedelta(minutes=5)
@classmethod
def should_run(self):
@@ -53,14 +53,9 @@ class Command(BaseCommand):
raise CommandError("Failed to get advisory lock, existing twitter_post process stuck?")
if settings.TWITTER_NEWS_TOKEN:
- articles = list(news_tweets_queryset().order_by('datetime'))
- else:
- articles = []
-
- if articles:
tw = Twitter()
- for a in articles:
+ for a in news_tweets_queryset().order_by('datetime'):
# We hardcode 30 chars for the URL shortener. And then 10 to cover the intro and spacing.
statusstr = "{0} {1}/news/{2}-{3}/".format(a.title[:140 - 40],
settings.SITEBASE,
@@ -73,35 +68,21 @@ class Command(BaseCommand):
else:
self.stderr.write("Failed to post to twitter: %s" % msg)
- # Don't post more often than once / 10 seconds, to not trigger flooding.
+ # Don't post more often than once / 10 seconds, to not trigger flooding detection.
time.sleep(10)
- # Find which conferences to tweet from. We will only put out one tweet for each
- # conference, expecting to be called again in 5 minutes or so to put out the
- # next one.
+ # Send off the conference twitter queue (which should normally only be one or two tweets, due to the filtering
+ # on datetime.
for c in conferences_with_tweets_queryset():
tw = Twitter(c)
- al = list(ConferenceNews.objects.filter(conference=c, tweeted=False, datetime__gt=datetime.now() - timedelta(days=7), datetime__lt=datetime.now(), conference__twittersync_active=True).order_by('datetime')[:1])
- if al:
- a = al[0]
- statusstr = "{0} {1}##{2}".format(a.title[:250 - 40],
- c.confurl,
- a.id)
- ok, msg = tw.post_tweet(statusstr)
+ for t in ConferenceTweetQueue.objects.filter(conference=c, approved=True, sent=False, datetime__lte=datetime.now()).order_by('datetime'):
+ ok, msg = tw.post_tweet(t.contents, t.image)
if ok:
- a.tweeted = True
- a.save()
- continue
+ t.sent = True
+ t.save(update_fields=['sent', ])
else:
self.stderr.write("Failed to post to twitter: %s" % msg)
- tl = list(ConferenceTweetQueue.objects.filter(conference=c).order_by('datetime')[:1])
- if tl:
- t = tl[0]
- ok, msg = tw.post_tweet(t.contents)
- if ok:
- t.delete()
- continue
- else:
- self.stderr.write("Failed to post to twitter: %s" % msg)
+ # Don't post more often than once / 10 seconds, to not trigger flooding detection.
+ time.sleep(10)
diff --git a/postgresqleu/urls.py b/postgresqleu/urls.py
index 38de801e..d2c7c55b 100644
--- a/postgresqleu/urls.py
+++ b/postgresqleu/urls.py
@@ -215,6 +215,9 @@ urlpatterns.extend([
url(r'^events/admin/(\w+)/discountcodes/(.*/)?$', postgresqleu.confreg.backendviews.edit_discountcodes),
url(r'^events/admin/(\w+)/accesstokens/(.*/)?$', postgresqleu.confreg.backendviews.edit_accesstokens),
url(r'^events/admin/(\w+)/news/(.*/)?$', postgresqleu.confreg.backendviews.edit_news),
+ url(r'^events/admin/(\w+)/tweet/queue/(.*/)?$', postgresqleu.confreg.backendviews.edit_tweetqueue),
+ url(r'^events/admin/(\w+)/tweet/campaign/$', postgresqleu.confreg.backendviews.tweetcampaignselect),
+ url(r'^events/admin/(\w+)/tweet/campaign/(\d+)/$', postgresqleu.confreg.backendviews.tweetcampaign),
url(r'^events/admin/(\w+)/pendinginvoices/$', postgresqleu.confreg.backendviews.pendinginvoices),
url(r'^events/admin/(\w+)/multiregs/$', postgresqleu.confreg.backendviews.multiregs),
url(r'^events/admin/(\w+)/addoptorders/$', postgresqleu.confreg.backendviews.addoptorders),
diff --git a/postgresqleu/util/docsviews.py b/postgresqleu/util/docsviews.py
index fdece1d0..a485f286 100644
--- a/postgresqleu/util/docsviews.py
+++ b/postgresqleu/util/docsviews.py
@@ -53,7 +53,7 @@ def docspage(request, page):
raise Http404()
with open(filename) as f:
- md = markdown.Markdown(extensions=['markdown.extensions.def_list'])
+ md = markdown.Markdown(extensions=['markdown.extensions.def_list', 'markdown.extensions.fenced_code'])
contents = md.convert(f.read())
contents = _reSvgInline.sub(lambda m: _replaceSvgInline(m, section), contents)
diff --git a/postgresqleu/util/messaging/twitter.py b/postgresqleu/util/messaging/twitter.py
index 06b2e585..eb321cf2 100644
--- a/postgresqleu/util/messaging/twitter.py
+++ b/postgresqleu/util/messaging/twitter.py
@@ -19,10 +19,27 @@ class Twitter(object):
token,
secret)
- def post_tweet(self, tweet):
- r = self.tw.post('https://api.twitter.com/1.1/statuses/update.json', data={
+ def get_own_screen_name(self):
+ r = self.tw.get('https://api.twitter.com/1.1/account/verify_credentials.json?include_entities=false&skip_status=true&include_email=false')
+ if r.status_code != 200:
+ raise Exception("http status {}".format(r.status_code))
+ return r.json()['screen_name']
+
+ def post_tweet(self, tweet, image=None):
+ d = {
'status': tweet,
- })
+ }
+
+ if image:
+ # Images are separately uploaded as a first step
+ r = self.tw.post('https://upload.twitter.com/1.1/media/upload.json', files={
+ 'media': bytearray(image),
+ })
+ if r.status_code != 200:
+ return (False, 'Media upload: {}'.format(r.text))
+ d['media_ids'] = r.json()['media_id']
+
+ r = self.tw.post('https://api.twitter.com/1.1/statuses/update.json', data=d)
if r.status_code != 200:
return (False, r.text)
return (True, None)
diff --git a/template/confreg/admin_dashboard_single.html b/template/confreg/admin_dashboard_single.html
index f538df59..4212c0aa 100644
--- a/template/confreg/admin_dashboard_single.html
+++ b/template/confreg/admin_dashboard_single.html
@@ -58,6 +58,10 @@
<h2>News</h2>
<div class="row">
<div class="col-md-3 col-sm-6 col-xs-12 buttonrow"><a class="btn btn-default btn-block" href="/events/admin/{{c.urlname}}/news/">News</a></div>
+{%if c.twittersync_active%}
+ <div class="col-md-3 col-sm-6 col-xs-12 buttonrow"><a class="btn btn-default btn-block {%if pending_tweet_approvals%}btn-danger{%elif pending_tweets%}btn-warning{%endif%}" href="/events/admin/{{c.urlname}}/tweet/queue/">Twitter post queue</a></div>
+ <div class="col-md-3 col-sm-6 col-xs-12 buttonrow"><a class="btn btn-default btn-block" href="/events/admin/{{c.urlname}}/tweet/campaign/">Create campaign</a></div>
+{%endif%}
</div>
<h2>Reports</h2>
diff --git a/template/confreg/admin_integ_twitter.html b/template/confreg/admin_integ_twitter.html
index 1035be04..81a1ad09 100644
--- a/template/confreg/admin_integ_twitter.html
+++ b/template/confreg/admin_integ_twitter.html
@@ -5,6 +5,9 @@
<h1>Twitter integration</h1>
{%if conference.twitter_token%}
<h3>Twitter integration currently configured</h3>
+<p>
+ Configured twitter user is <strong>{{conference.twitter_user}}</strong>.
+</p>
<form class="form-horizontal" method="POST" action="." enctype="multipart/form-data">{%csrf_token%}
{%include "confreg/admin_backend_form_content.html" %}
</form>