diff --git a/kepi/trilby_api/migrations/0010_auto_20200316_1723.py b/kepi/trilby_api/migrations/0010_auto_20200316_1723.py new file mode 100644 index 0000000..fadaa45 --- /dev/null +++ b/kepi/trilby_api/migrations/0010_auto_20200316_1723.py @@ -0,0 +1,56 @@ +# Generated by Django 2.2.4 on 2020-03-16 17:23 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('trilby_api', '0009_auto_20191218_1751'), + ] + + operations = [ + migrations.CreateModel( + name='Person', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('remote_url', models.URLField(blank=True, max_length=255, null=True, unique=True)), + ('remote_username', models.CharField(blank=True, max_length=255, null=True)), + ('icon_image', models.ImageField(help_text='A small square image used to identify you.', null=True, upload_to='', verbose_name='icon')), + ('header_image', models.ImageField(help_text="A large image, wider than it's tall, which appears at the top of your profile page.", null=True, upload_to='', verbose_name='header image')), + ('display_name', models.TextField(help_text='Your name, in human-friendly form. Something like "Alice Liddell".', verbose_name='display name')), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('publicKey', models.TextField(blank=True, null=True, verbose_name='public key')), + ('privateKey', models.TextField(blank=True, null=True, verbose_name='private key')), + ('note', models.TextField(default='', help_text='Your biography. Something like "I enjoy falling down rabbitholes."', max_length=255, verbose_name='bio')), + ('auto_follow', models.BooleanField(default=True, help_text='If True, follow requests will be accepted automatically.')), + ], + ), + migrations.RemoveField( + model_name='trilbyuser', + name='actor', + ), + migrations.CreateModel( + name='Status', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.TextField()), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('sensitive', models.BooleanField()), + ('spoiler_text', models.CharField(max_length=255)), + ('visibility', models.CharField(max_length=255)), + ('language', models.CharField(max_length=255)), + ('idempotency_key', models.CharField(max_length=255)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='trilby_api.Person')), + ('in_reply_to_id', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='trilby_api.Status')), + ], + ), + migrations.AddField( + model_name='person', + name='local_user', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/kepi/trilby_api/models.py b/kepi/trilby_api/models.py index 3e96418..b5f85b4 100644 --- a/kepi/trilby_api/models.py +++ b/kepi/trilby_api/models.py @@ -5,47 +5,217 @@ import kepi.bowler_pub.models as kepi_models import kepi.bowler_pub.signals as kepi_signals import kepi.bowler_pub.find as kepi_find from kepi.bowler_pub.create import create +import kepi.bowler_pub.crypto as crypto from django.utils.timezone import now +from django.core.exceptions import ValidationError import logging logger = logging.Logger('kepi') class TrilbyUser(AbstractUser): + """ + A Django user. + """ + pass - actor = models.OneToOneField( - kepi_models.AcPerson, - on_delete=models.CASCADE, - unique=True, - default=None, +class Person(models.Model): + + remote_url = models.URLField( + max_length = 255, + null = True, + blank = True, + unique = True, ) + remote_username = models.CharField( + max_length = 255, + null = True, + blank = True, + ) + + local_user = models.OneToOneField( + to = TrilbyUser, + on_delete = models.CASCADE, + null = True, + blank = True, + ) + + icon_image = models.ImageField( + help_text="A small square image used to identify you.", + null=True, + verbose_name='icon', + ) + + header_image = models.ImageField( + help_text="A large image, wider than it's tall, which appears "+\ + "at the top of your profile page.", + null=True, + verbose_name='header image', + ) + + @property + def icon_or_default(self): + if self.icon_image: + return self.icon_image + + which = ord(self.id[1]) % 10 + return uri_to_url('/static/defaults/avatar_{}.jpg'.format( + which, + )) + + @property + def header_or_default(self): + if self.header_image: + return self.header_image + + return uri_to_url('/static/defaults/header.jpg') + + display_name = models.TextField( + verbose_name='display name', + help_text = 'Your name, in human-friendly form. '+\ + 'Something like "Alice Liddell".', + ) + + created_at = models.DateTimeField( + default = now, + ) + + publicKey = models.TextField( + blank=True, + null=True, + verbose_name='public key', + ) + + privateKey = models.TextField( + blank=True, + null=True, + verbose_name='private key', + ) + + note = models.TextField( + max_length=255, + help_text="Your biography. Something like "+\ + '"I enjoy falling down rabbitholes."', + default='', + verbose_name='bio', + ) + + auto_follow = models.BooleanField( + default=True, + help_text="If True, follow requests will be accepted automatically.", + ) + + @property + def following_count(self): + return 0 # FIXME + + @property + def followers_count(self): + return 0 # FIXME + + @property + def statuses_count(self): + return 0 # FIXME + + @property + def acct(self): + return 'FIXME' # FIXME + + @property + def username(self): + if remote_url is not None: + return remote_username + else: + return local_user.username + + def _generate_keys(self): + + logger.info('%s: generating key pair.', + self.url) + + key = crypto.Key() + self.privateKey = key.private_as_pem() + self.publicKey = key.public_as_pem() + def save(self, *args, **kwargs): + + # Validate: either remote or local but not both or neither. + remote_set = \ + self.remote_url is not None and \ + self.remote_username is not None - if self.pk is None and self.actor is None: + local_set = \ + self.local_user is not None - name = self.get_username() - - logger.info('Creating AcPerson for new user "%s".', - name) - - spec = { - 'name': name, - 'id': '@'+name, - 'type': 'Person', - } - - new_person = create( - value = spec, - run_delivery = False, + if local_set == remote_set: + raise ValidationError( + "Either local or remote fields must be set." ) - self.actor = new_person + # Create keys, if we're local and we don't have them. - logger.info(' -- new AcPerson is %s', - new_person) + if local_set and self.privateKey is None and self.publicKey is None: + self._generate_keys() + + # All good. super().save(*args, **kwargs) +################### + +class Status(models.Model): + + # TODO: The original design has the serial number + # monotonically but unpredictably increasing. + + @property + def url(self): + return 'FIXME' # FIXME + + @property + def uri(self): + return 'FIXME' # FIXME + + account = models.ForeignKey( + 'Person', + on_delete = models.DO_NOTHING, + ) + + in_reply_to_id = models.ForeignKey( + 'self', + on_delete = models.DO_NOTHING, + ) + + content = models.TextField( + ) + + created_at = models.DateTimeField( + default = now, + ) + + # TODO Media + + sensitive = models.BooleanField( + ) + + spoiler_text = models.CharField( + max_length = 255, + ) + + visibility = models.CharField( + max_length = 255, + ) + + language = models.CharField( + max_length = 255, + ) + + idempotency_key = models.CharField( + max_length = 255, + ) + +################### + class Notification(models.Model): FOLLOW = 'F' diff --git a/kepi/trilby_api/serializers.py b/kepi/trilby_api/serializers.py index 7d327b4..1a78098 100644 --- a/kepi/trilby_api/serializers.py +++ b/kepi/trilby_api/serializers.py @@ -40,7 +40,6 @@ class UserSerializer(serializers.ModelSerializer): ) note = serializers.CharField( - source='f_summary', ) following_count = serializers.IntegerField() diff --git a/kepi/trilby_api/tests/__init__.py b/kepi/trilby_api/tests/__init__.py index 8c6105c..b9128e3 100644 --- a/kepi/trilby_api/tests/__init__.py +++ b/kepi/trilby_api/tests/__init__.py @@ -1,18 +1,19 @@ -from kepi.trilby_api.models import TrilbyUser +from kepi.trilby_api.models import * PUBLIC = "https://www.w3.org/ns/activitystreams#Public" -def create_local_trilbyuser(name='jemima'): +def create_local_person(name='jemima'): - from kepi.bowler_pub.tests import create_local_person from kepi.trilby_api.models import TrilbyUser - person = create_local_person(name=name) - - result = TrilbyUser( + user = TrilbyUser( username = name, - actor = person) - result.save() + ) + user.save() + + result = Person( + local_user = user, + ) return result diff --git a/kepi/trilby_api/tests/test_integration.py b/kepi/trilby_api/tests/test_integration.py index 7b53a86..8db6c09 100644 --- a/kepi/trilby_api/tests/test_integration.py +++ b/kepi/trilby_api/tests/test_integration.py @@ -10,7 +10,7 @@ class TestIntegration(TestCase): settings.KEPI['LOCAL_OBJECT_HOSTNAME'] = 'testserver' def _create_alice(self): - self._alice = create_local_trilbyuser(name='alice') + self._alice = create_local_person(name='alice') def test_post(self): diff --git a/kepi/trilby_api/tests/test_notifications.py b/kepi/trilby_api/tests/test_notifications.py index 7c0011f..bf1014d 100644 --- a/kepi/trilby_api/tests/test_notifications.py +++ b/kepi/trilby_api/tests/test_notifications.py @@ -20,7 +20,7 @@ class TestNotifications(TestCase): @httpretty.activate def test_follow(self): - alice = create_local_trilbyuser(name='alice') + alice = create_local_person(name='alice') fred_keys = json.load(open(DEFAULT_KEYS_FILENAME, 'r')) @@ -84,7 +84,7 @@ class TestNotifications(TestCase): @httpretty.activate def test_favourite(self): - alice = create_local_trilbyuser(name='alice') + alice = create_local_person(name='alice') status = create_local_status( content = 'Curiouser and curiouser!', diff --git a/kepi/trilby_api/tests/test_rest.py b/kepi/trilby_api/tests/test_rest.py index 73d6634..e45c914 100644 --- a/kepi/trilby_api/tests/test_rest.py +++ b/kepi/trilby_api/tests/test_rest.py @@ -109,7 +109,7 @@ class TestRest(TestCase): ) def _user_test(self, name): - alice = create_local_trilbyuser(name='alice') + alice = create_local_person(name='alice') request = self.factory.get( '/api/v1/accounts/'+name, @@ -149,7 +149,7 @@ class TestStatuses(TestCase): settings.KEPI['LOCAL_OBJECT_HOSTNAME'] = 'testserver' def _create_alice(self): - self._alice = create_local_trilbyuser(name='alice') + self._alice = create_local_person(name='alice') def _create_status(self): self._status = create_local_status( diff --git a/kepi/trilby_api/tests/test_status.py b/kepi/trilby_api/tests/test_status.py index 8c15d65..86549e1 100644 --- a/kepi/trilby_api/tests/test_status.py +++ b/kepi/trilby_api/tests/test_status.py @@ -8,7 +8,7 @@ from django.conf import settings class TestStatus(TestCase): def _create_alice(self): - self._alice = create_local_trilbyuser(name='alice') + self._alice = create_local_person(name='alice') self._alice_status = create_local_status( posted_by = self._alice, diff --git a/kepi/trilby_api/tests/test_timelines.py b/kepi/trilby_api/tests/test_timelines.py index 7895db0..c05fce2 100644 --- a/kepi/trilby_api/tests/test_timelines.py +++ b/kepi/trilby_api/tests/test_timelines.py @@ -31,7 +31,7 @@ class PublicTimeline(TestCase): self.assertEqual(len(response), 0) def test_public_singleton(self): - self._alice = create_local_trilbyuser(name='alice') + self._alice = create_local_person(name='alice') self._status = create_local_status( content = 'Hello world.', @@ -47,7 +47,7 @@ class PublicTimeline(TestCase): ) def test_public_singleton_hidden(self): - self._alice = create_local_trilbyuser(name='alice') + self._alice = create_local_person(name='alice') self._status = create_local_status( content = 'Hello world.',