Skip to content

Commit 9ff5f2a

Browse files
committed
Simple API for connecting bots.
1 parent 0dd00c5 commit 9ff5f2a

18 files changed

+417
-1
lines changed

requirements/requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,7 @@ django-bootstrap4==3.0.1
2626
libsasscompiler==0.1.8
2727
django-debug-toolbar==3.2.1
2828
django-admin-numeric-filter==0.1.6
29+
djangorestframework==3.12.4
30+
django-filter==2.4.0
2931

3032
sentry-sdk==0.12.2

scripts/bot-run.py

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Script for running a simple bot.
4+
"""
5+
import json
6+
import subprocess
7+
from urllib.parse import urljoin
8+
from urllib.request import Request, urlopen
9+
10+
11+
class API:
12+
def __init__(self, base_url, token):
13+
self.base_url = base_url
14+
self.token = token
15+
16+
def request(self, url, method='GET', data=None):
17+
url = urljoin(self.base_url, url)
18+
if data:
19+
data = json.dumps(data).encode('utf-8')
20+
else:
21+
data = None
22+
23+
headers = {
24+
"Content-type": "application/json",
25+
}
26+
if self.token:
27+
headers['Authorization'] = 'Token ' + self.token
28+
29+
req = Request(url, data=data, method=method, headers=headers)
30+
try:
31+
resp = urlopen(req)
32+
except Exception as e:
33+
print(e.reason)
34+
print(e.read().decode('utf-8'))
35+
raise
36+
else:
37+
return json.load(resp)
38+
39+
def my_chunks(self):
40+
me = self.request('me/')['id']
41+
return self.request('documents/chunks/?user={}'.format(me))
42+
43+
44+
def process_chunk(chunk, api, executable):
45+
print(chunk['id'])
46+
head = chunk['head']
47+
text = api.request(head)['text']
48+
text = text.encode('utf-8')
49+
50+
try:
51+
p = subprocess.run(
52+
[executable],
53+
input=text,
54+
capture_output=True,
55+
check=True
56+
)
57+
except subprocess.CalledProcessError as e:
58+
print('Ditching the update. Bot exited with error code {} and output:'.format(e.returncode))
59+
print(e.stderr.decode('utf-8'))
60+
return
61+
result_text = p.stdout.decode('utf-8')
62+
stderr_text = p.stderr.decode('utf-8')
63+
api.request(chunk['revisions'], 'POST', {
64+
"parent": head,
65+
"description": stderr_text or 'Automatic update.',
66+
"text": result_text
67+
})
68+
# Remove the user assignment.
69+
api.request(chunk['id'], 'PUT', {
70+
"user": None
71+
})
72+
73+
74+
if __name__ == '__main__':
75+
import argparse
76+
parser = argparse.ArgumentParser(
77+
description='Runs a bot for Redakcja. '
78+
'You need to provide an executable which will take current revision '
79+
'of text as stdin, and output the new version on stdout. '
80+
'Any output given on stderr will be used as revision description. '
81+
'If bot exits with non-zero return code, the update will be ditched.'
82+
)
83+
parser.add_argument(
84+
'token', metavar='TOKEN', help='A Redakcja API token.'
85+
)
86+
parser.add_argument(
87+
'executable', metavar='EXECUTABLE', help='An executable to run as bot.'
88+
)
89+
parser.add_argument(
90+
'--api', metavar='API', help='A base URL for the API.',
91+
default='https://redakcja.wolnelektury.pl/api/',
92+
)
93+
args = parser.parse_args()
94+
95+
96+
api = API(args.api, args.token)
97+
98+
chunks = api.my_chunks()
99+
if chunks:
100+
for chunk in api.my_chunks():
101+
process_chunk(chunk, api, args.executable)
102+
else:
103+
print('No assigned chunks found.')

src/documents/api/serializers.py

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from rest_framework import serializers
2+
from .. import models
3+
4+
5+
class TextField(serializers.Field):
6+
def get_attribute(self, instance):
7+
return instance
8+
9+
def to_representation(self, value):
10+
return value.materialize()
11+
12+
def to_internal_value(self, data):
13+
return data
14+
15+
16+
class BookSerializer(serializers.ModelSerializer):
17+
id = serializers.HyperlinkedIdentityField(view_name='documents_api_book')
18+
19+
class Meta:
20+
model = models.Book
21+
fields = [
22+
'id',
23+
'title'
24+
]
25+
26+
27+
class ChunkSerializer(serializers.ModelSerializer):
28+
id = serializers.HyperlinkedIdentityField(view_name='documents_api_chunk')
29+
book = serializers.HyperlinkedRelatedField(view_name='documents_api_book', read_only=True)
30+
revisions = serializers.HyperlinkedIdentityField(view_name='documents_api_chunk_revision_list')
31+
head = serializers.HyperlinkedRelatedField(view_name='documents_api_revision', read_only=True)
32+
## RelatedField
33+
34+
class Meta:
35+
model = models.Chunk
36+
fields = ['id', 'book', 'revisions', 'head', 'user', 'stage']
37+
38+
39+
class RHRF(serializers.HyperlinkedRelatedField):
40+
def get_queryset(self):
41+
return self.context['chunk'].change_set.all();
42+
43+
44+
class RevisionSerializer(serializers.ModelSerializer):
45+
id = serializers.HyperlinkedIdentityField(view_name='documents_api_revision')
46+
parent = RHRF(
47+
view_name='documents_api_revision',
48+
queryset=models.Chunk.change_model.objects.all()
49+
)
50+
merge_parent = RHRF(
51+
view_name='documents_api_revision',
52+
read_only=True
53+
)
54+
chunk = serializers.HyperlinkedRelatedField(view_name='documents_api_chunk', read_only=True, source='tree')
55+
author = serializers.SerializerMethodField()
56+
57+
class Meta:
58+
model = models.Chunk.change_model
59+
fields = ['id', 'chunk', 'created_at', 'author', 'author_email', 'author_name', 'parent', 'merge_parent']
60+
read_only_fields = ['author_email', 'author_name']
61+
62+
def get_author(self, obj):
63+
return obj.author.username if obj.author is not None else None
64+
65+
66+
class BookDetailSerializer(BookSerializer):
67+
chunk = ChunkSerializer(many=True, source='chunk_set')
68+
69+
class Meta:
70+
model = models.Book
71+
fields = BookSerializer.Meta.fields + ['chunk']
72+
73+
74+
75+
class ChunkDetailSerializer(ChunkSerializer):
76+
pass
77+
78+
79+
class RevisionDetailSerializer(RevisionSerializer):
80+
text = TextField()
81+
82+
class Meta(RevisionSerializer.Meta):
83+
fields = RevisionSerializer.Meta.fields + ['description', 'text']
84+
85+
def create(self, validated_data):
86+
chunk = self.context['chunk']
87+
return chunk.commit(
88+
validated_data['text'],
89+
author=self.context['request'].user, # what if anonymous?
90+
description=validated_data['description'],
91+
parent=validated_data.get('parent'),
92+
)

src/documents/api/urls.py

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from django.urls import path
2+
from . import views
3+
4+
5+
urlpatterns = [
6+
path('books/', views.BookList.as_view(),
7+
name='documents_api_book_list'),
8+
path('books/<int:pk>/', views.BookDetail.as_view(),
9+
name='documents_api_book'),
10+
path('chunks/', views.ChunkList.as_view(),
11+
name='documents_api_chunk_list'),
12+
path('chunks/<int:pk>/', views.ChunkDetail.as_view(),
13+
name='documents_api_chunk'),
14+
path('chunks/<int:pk>/revisions/', views.ChunkRevisionList.as_view(),
15+
name='documents_api_chunk_revision_list'),
16+
path('revisions/<int:pk>/', views.RevisionDetail.as_view(),
17+
name='documents_api_revision'),
18+
]

src/documents/api/views.py

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from rest_framework.generics import RetrieveAPIView, RetrieveUpdateAPIView, ListAPIView, ListCreateAPIView
2+
from rest_framework.permissions import IsAuthenticatedOrReadOnly
3+
from django.http import Http404
4+
from .. import models
5+
from . import serializers
6+
7+
8+
class BookList(ListAPIView):
9+
serializer_class = serializers.BookSerializer
10+
search_fields = ['title']
11+
12+
def get_queryset(self):
13+
return models.Book.get_visible_for(self.request.user)
14+
15+
16+
class BookDetail(RetrieveAPIView):
17+
serializer_class = serializers.BookDetailSerializer
18+
19+
def get_queryset(self):
20+
return models.Book.get_visible_for(self.request.user)
21+
22+
23+
class ChunkList(ListAPIView):
24+
queryset = models.Chunk.objects.all()
25+
serializer_class = serializers.ChunkSerializer
26+
filter_fields = ['user', 'stage']
27+
search_fields = ['book__title']
28+
29+
def get_queryset(self):
30+
return models.Chunk.get_visible_for(self.request.user)
31+
32+
33+
class ChunkDetail(RetrieveUpdateAPIView):
34+
permission_classes = [IsAuthenticatedOrReadOnly]
35+
serializer_class = serializers.ChunkDetailSerializer
36+
37+
def get_queryset(self):
38+
return models.Chunk.get_visible_for(self.request.user)
39+
40+
41+
class ChunkRevisionList(ListCreateAPIView):
42+
permission_classes = [IsAuthenticatedOrReadOnly]
43+
serializer_class = serializers.RevisionSerializer
44+
45+
def get_serializer_class(self):
46+
if self.request.method == 'POST':
47+
return serializers.RevisionDetailSerializer
48+
else:
49+
return serializers.RevisionSerializer
50+
51+
def get_serializer_context(self):
52+
ctx = super().get_serializer_context()
53+
try:
54+
ctx["chunk"] = models.Chunk.objects.get(pk=self.kwargs['pk'])
55+
except models.Chunk.DoesNotExist:
56+
raise Http404
57+
return ctx
58+
59+
def get_queryset(self):
60+
try:
61+
return models.Chunk.get_visible_for(self.request.user).get(
62+
pk=self.kwargs['pk']
63+
).change_set.all()
64+
except models.Chunk.DoesNotExist:
65+
raise Http404()
66+
67+
68+
class RevisionDetail(RetrieveAPIView):
69+
queryset = models.Chunk.change_model.objects.all()
70+
serializer_class = serializers.RevisionDetailSerializer
71+
72+
def get_queryset(self):
73+
return models.Chunk.get_revisions_visible_for(self.request.user)
74+

src/documents/models/book.py

+6
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ class Meta:
6060
verbose_name = _('book')
6161
verbose_name_plural = _('books')
6262

63+
@classmethod
64+
def get_visible_for(cls, user):
65+
qs = cls.objects.all()
66+
if not user.is_authenticated:
67+
qs = qs.filter(public=True)
68+
return qs
6369

6470
# Representing
6571
# ============

src/documents/models/chunk.py

+14
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,20 @@ class Meta:
3939
verbose_name_plural = _('chunks')
4040
permissions = [('can_pubmark', 'Can mark for publishing')]
4141

42+
@classmethod
43+
def get_visible_for(cls, user):
44+
qs = cls.objects.all()
45+
if not user.is_authenticated:
46+
qs = qs.filter(book__public=True)
47+
return qs
48+
49+
@classmethod
50+
def get_revisions_visible_for(cls, user):
51+
qs = cls.change_model.objects.all()
52+
if not user.is_authenticated:
53+
qs = qs.filter(tree__book__public=True)
54+
return qs
55+
4256
# Representing
4357
# ============
4458

src/dvcs/models.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ class Meta:
9494
unique_together = ['tree', 'revision']
9595

9696
def __str__(self):
97-
return u"Id: %r, Tree %r, Parent %r, Data: %s" % (self.id, self.tree_id, self.parent_id, self.data)
97+
return "rev. {} @ {}".format(self.revision, self.created_at)
9898

9999
def author_str(self):
100100
if self.author:

src/redakcja/api/admin.py

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.contrib import admin
2+
from . import models
3+
4+
5+
@admin.register(models.Token)
6+
class TokenAdmin(admin.ModelAdmin):
7+
readonly_fields = ['key', 'created', 'last_seen_at']

src/redakcja/api/auth.py

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from django.utils.timezone import now
2+
from rest_framework.authentication import TokenAuthentication as BaseTokenAuthentication
3+
from . import models
4+
5+
6+
class TokenAuthentication(BaseTokenAuthentication):
7+
model = models.Token
8+
9+
def authenticate_credentials(self, key):
10+
user, token = super().authenticate_credentials(key)
11+
token.last_seen_at = now()
12+
token.save(update_fields=['last_seen_at'])
13+
return (user, token)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Generated by Django 3.1.13 on 2021-09-10 15:04
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
initial = True
11+
12+
dependencies = [
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name='Token',
19+
fields=[
20+
('key', models.CharField(max_length=40, primary_key=True, serialize=False, verbose_name='Key')),
21+
('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
22+
('last_seen_at', models.DateTimeField(blank=True, null=True)),
23+
('api_version', models.IntegerField(choices=[(1, '1')])),
24+
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='auth_token', to=settings.AUTH_USER_MODEL, verbose_name='User')),
25+
],
26+
options={
27+
'verbose_name': 'Token',
28+
'verbose_name_plural': 'Tokens',
29+
'abstract': False,
30+
},
31+
),
32+
]

src/redakcja/api/migrations/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)