Skip to content

Commit 03275f3

Browse files
committed
Implement pinning to social networks
This is mostly untested so far, since we need some real posts to pin before we can do that, but "mostly works".
1 parent d8c5806 commit 03275f3

5 files changed

Lines changed: 182 additions & 3 deletions

File tree

pgweb/news/admin.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
from django import forms
33

44
from pgweb.util.admin import PgwebAdmin
5+
from pgweb.util.moderation import ModerationState
56
from pgweb.core.models import OrganisationEmail
6-
from .models import NewsArticle, NewsTag
7+
from .models import NewsArticle, NewsTag, PinnedNewsArticle
8+
9+
from datetime import datetime, timedelta
710

811

912
class NewsArticleAdminForm(forms.ModelForm):
@@ -20,14 +23,36 @@ class NewsArticleAdmin(PgwebAdmin):
2023
list_filter = ('modstate', )
2124
filter_horizontal = ('tags', )
2225
search_fields = ('content', 'title', )
23-
exclude = ('modstate', 'firstmoderator', )
26+
exclude = ('modstate', 'firstmoderator', 'ispinned', )
2427
form = NewsArticleAdminForm
2528

2629

30+
class PinnedNewsArticleAdminForm(forms.ModelForm):
31+
model = PinnedNewsArticle
32+
33+
def __init__(self, *args, **kwargs):
34+
super().__init__(*args, **kwargs)
35+
self.fields['pinnedarticle'].queryset = NewsArticle.objects.filter(modstate=ModerationState.APPROVED, date__gt=datetime.now() - timedelta(days=365)).order_by('-date')
36+
self.fields['pinnedarticle'].widget.can_delete_related = False
37+
self.fields['pinnedarticle'].widget.can_add_related = False
38+
39+
40+
class PinnedNewsArticleAdmin(admin.ModelAdmin):
41+
exclude = ('pinnedtoproviders', )
42+
form = PinnedNewsArticleAdminForm
43+
44+
def has_add_permission(self, request, obj=None):
45+
return False
46+
47+
def has_delete_permission(self, request, obj=None):
48+
return False
49+
50+
2751
class NewsTagAdmin(PgwebAdmin):
2852
list_display = ('urlname', 'name', 'description')
2953
filter_horizontal = ('allowed_orgs', )
3054

3155

3256
admin.site.register(NewsArticle, NewsArticleAdmin)
3357
admin.site.register(NewsTag, NewsTagAdmin)
58+
admin.site.register(PinnedNewsArticle, PinnedNewsArticleAdmin)

pgweb/news/management/commands/social_post.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import time
1414

1515
from pgweb.util.moderation import ModerationState
16-
from pgweb.news.models import NewsArticle
16+
from pgweb.news.models import NewsArticle, PinnedNewsArticle
1717
from pgweb.util.socialposter import get_all_providers
1818

1919

@@ -50,3 +50,11 @@ def handle(self, *args, **options):
5050
if postid is not None:
5151
a.postedto[p.name] = postid
5252
a.save(update_fields=['postedto'])
53+
54+
# Pin or unpin any articles as needed
55+
pna = PinnedNewsArticle.objects.select_related('pinnedarticle').only('pinnedarticle', 'pinnedtoproviders', 'pinnedarticle__postedto').all()[0]
56+
for p in allproviders:
57+
if pna.pinnedtoproviders.get(p.name, None) != pna.pinnedarticle.postedto.get(p.name, None):
58+
if p.set_pin(pna.pinnedarticle.postedto.get(p.name, None)):
59+
pna.pinnedtoproviders[p.name] = pna.pinnedarticle.postedto.get(p.name, None)
60+
pna.save(update_fields=['pinnedtoproviders'])
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Generated by Django 5.2.10 on 2026-04-06 20:14
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
def add_record(apps, schema_editor):
8+
apps.get_model('news', 'PinnedNewsArticle')(pinnedarticle=None).save()
9+
10+
11+
class Migration(migrations.Migration):
12+
13+
dependencies = [
14+
('news', '0008_multisocial'),
15+
]
16+
17+
operations = [
18+
migrations.CreateModel(
19+
name='PinnedNewsArticle',
20+
fields=[
21+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22+
('pinnedtoproviders', models.JSONField(blank=True, default=dict)),
23+
('pinnedarticle', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='news.newsarticle')),
24+
],
25+
),
26+
migrations.RunPython(add_record),
27+
]

pgweb/news/models.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from datetime import date
33
from pgweb.core.models import Organisation, OrganisationEmail
44
from pgweb.core.text import ORGANISATION_HINT_TEXT
5+
from django.core.validators import ValidationError
56
from pgweb.util.moderation import TristateModerateModel, ModerationState, TwoModeratorsMixin
67
from django.template.defaultfilters import slugify
78

@@ -113,3 +114,18 @@ def get_field_description(self, f):
113114
return 'Content preview'
114115
elif f == 'permanenturl':
115116
return 'Permanent URL'
117+
118+
119+
class PinnedNewsArticle(models.Model):
120+
pinnedarticle = models.ForeignKey(NewsArticle, null=True, blank=True, on_delete=models.SET_NULL)
121+
pinnedtoproviders = models.JSONField(null=False, blank=True, default=dict)
122+
123+
def save(self, *args, **kwargs):
124+
if not self.pk and PinnedNewsArticle.objects.exists():
125+
raise ValidationError("Only one PinnedNewsArticle may exist!")
126+
return super().save(*args, **kwargs)
127+
128+
def __str__(self):
129+
if self.pinnedarticle:
130+
return str(self.pinnedarticle)
131+
return 'No article currently pinned'

pgweb/util/socialposter.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ def post(self):
2222
def register(self, clientname):
2323
raise NotImplementedError
2424

25+
def set_pin(self, postid):
26+
raise NotImplementedError
27+
2528

2629
class Mastodon(SocialPoster):
2730
name = 'mastodon'
@@ -42,6 +45,57 @@ def post(self, text):
4245

4346
return r.json()['id']
4447

48+
def set_pin(self, postid):
49+
# First we have to see if there is an existing pin that has to be removed. To do that, we
50+
# have to fetch our account id.
51+
r = requests.get(
52+
'{}/api/v1/accounts/verify_credentials '.format(self.settings.MASTODON_BASEURL),
53+
headers={'Authorization': 'Bearer {}'.format(self.settings.MASTODON_TOKEN)},
54+
timeout=10,
55+
)
56+
if r.status_code != 200:
57+
print("Failed to get mastodon credentials: {}".format(r.text))
58+
return False
59+
60+
# Next, get the currently pinned ones
61+
r = requests.get(
62+
'{}/api/v1/accounts/{}/statuses'.format(self.settings.MASTODON_BASEURL, r.json()['id']),
63+
headers={'Authorization': 'Bearer {}'.format(self.settings.MASTODON_TOKEN)},
64+
params={'pinned': 'true'},
65+
timeout=10,
66+
)
67+
if r.status_code != 200:
68+
print("Failed to get list of mastodon pins: {}".format(r.text))
69+
return False
70+
71+
found = False
72+
for p in r.json():
73+
if p['id'] == postid:
74+
# Already pinned!
75+
found = True
76+
else:
77+
# This should be unpinned
78+
r2 = requests.post(
79+
'{}/api/v1/statuses{}/unpin'.format(self.settings.MASTODON_BASEURL, p['id']),
80+
headers={'Authorization': 'Bearer {}'.format(self.settings.MASTODON_TOKEN)},
81+
timeout=10,
82+
)
83+
if r.status_code != 200:
84+
print("Failed to unpin from mastodon: {}".format(r.text))
85+
86+
if not found:
87+
# Not already pinned, so pin!
88+
r2 = requests.post(
89+
'{}/api/v1/statuses{}/pin'.format(self.settings.MASTODON_BASEURL, postid),
90+
headers={'Authorization': 'Bearer {}'.format(self.settings.MASTODON_TOKEN)},
91+
timeout=10,
92+
)
93+
if r.status_code != 200:
94+
print("Failed to pin to mastodon: {}".format(r.text))
95+
return False
96+
97+
return True
98+
4599
def register(self, clientname):
46100
toadd = io.StringIO()
47101

@@ -156,6 +210,55 @@ def post(self, text):
156210

157211
return r.json()['uri']
158212

213+
def set_pin(self, postid):
214+
# Get our profile, so we can "patch" it
215+
r = requests.get(
216+
'https://bsky.social/xrpc/com.atproto.repo.getRecord',
217+
headers={'Authorization': 'Bearer {}'.format(self.token)},
218+
params={'repo': self.repo, 'collection': 'app.bsky.actor.profile', 'rkey': 'self'},
219+
timeout=10,
220+
)
221+
if r.status_code != 200:
222+
print("Failed to get bluesky profile: {}".format(r.text))
223+
return False
224+
record = r.json()['value']
225+
if postid:
226+
# We have the uri, but we need both the uri and the cid, so fetch the post
227+
r2 = requests.get(
228+
'https://bsky.social/xrpc/app.bsky.feed.getPosts',
229+
headers={'Authorization': 'Bearer {}'.format(self.token)},
230+
params={'uris': postid},
231+
timeout=10,
232+
)
233+
if r.status_code != 200:
234+
print("Failed to read back bluesky post {}: {}".format(postid, r.text))
235+
return False
236+
237+
cid = r2.json()['posts'][0]['cid']
238+
239+
record['pinnedPost'] = {
240+
'uri': postid,
241+
'cid': cid,
242+
}
243+
else:
244+
if 'pinnedPost' in record:
245+
del record['pinnedPost']
246+
247+
if record != r.json()['value']:
248+
# Some changes, so we need to update
249+
r = requests.post(
250+
'https://bsky.social/xrpc/com.atproto.repo.putRecord',
251+
headers={'Authorization': 'Bearer {}'.format(self.token)},
252+
json={'repo': self.repo, 'collection': 'app.bsky.actor.profile', 'rkey': 'self', 'record': record},
253+
timeout=10,
254+
)
255+
if r.status_code != 200:
256+
print("Failed to save pack bluesky profile for pinned posts: {}".format(r.text))
257+
return False
258+
return True
259+
260+
return True
261+
159262
def register(self, clientname):
160263
if getattr(self.settings, 'BLUESKY_USER', None) is None or getattr(self.settings, 'BLUESKY_PASSWORD', None) is None:
161264
return "For bluesky, add an 'app password' from 'Settings' -> 'Privacy and Security'.\nRegister the account email as BLUESKY_USER and the app password as BLUESKY_PASSWORD.\n"

0 commit comments

Comments
 (0)