diff options
| author | Magnus Hagander | 2019-08-21 14:14:11 +0000 |
|---|---|---|
| committer | Magnus Hagander | 2019-08-21 15:11:22 +0000 |
| commit | c2451c4eacf222ab4e3f93d845eef1cbf06618a2 (patch) | |
| tree | 11f4de28fc7311080d8cd6e4eca30107808cccf2 | |
| parent | e2a304163b25c221923dacee8165b8efdcb06916 (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.md | 1 | ||||
| -rw-r--r-- | docs/confreg/integrations.md | 59 | ||||
| -rw-r--r-- | postgresqleu/confreg/backendforms.py | 70 | ||||
| -rw-r--r-- | postgresqleu/confreg/backendviews.py | 77 | ||||
| -rw-r--r-- | postgresqleu/confreg/campaigns.py | 141 | ||||
| -rw-r--r-- | postgresqleu/confreg/management/commands/confreg_post_news.py | 43 | ||||
| -rw-r--r-- | postgresqleu/confreg/migrations/0057_twitter_enhancement.py | 49 | ||||
| -rw-r--r-- | postgresqleu/confreg/models.py | 14 | ||||
| -rw-r--r-- | postgresqleu/confreg/views.py | 4 | ||||
| -rw-r--r-- | postgresqleu/confsponsor/views.py | 2 | ||||
| -rw-r--r-- | postgresqleu/newsevents/management/commands/twitter_post.py | 43 | ||||
| -rw-r--r-- | postgresqleu/urls.py | 3 | ||||
| -rw-r--r-- | postgresqleu/util/docsviews.py | 2 | ||||
| -rw-r--r-- | postgresqleu/util/messaging/twitter.py | 23 | ||||
| -rw-r--r-- | template/confreg/admin_dashboard_single.html | 4 | ||||
| -rw-r--r-- | template/confreg/admin_integ_twitter.html | 3 |
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> |
