kopia lustrzana https://gitlab.com/marnanel/chapeau
455 wiersze
12 KiB
Python
455 wiersze
12 KiB
Python
from django.db import models, IntegrityError
|
|
from django.conf import settings
|
|
from django.utils import timezone
|
|
from polymorphic.models import PolymorphicModel
|
|
from polymorphic.managers import PolymorphicManager
|
|
from chapeau.bowler_pub.models.audience import Audience, AUDIENCE_FIELD_NAMES
|
|
from chapeau.bowler_pub.models.thingfield import ThingField
|
|
from chapeau.bowler_pub.models.mention import Mention
|
|
from chapeau.bowler_pub.utils import configured_path, uri_to_url
|
|
from .. import URL_REGEXP, SERIAL_NUMBER_REGEXP
|
|
import chapeau.bowler_pub.side_effects as side_effects
|
|
import logging
|
|
import random
|
|
import warnings
|
|
import re
|
|
|
|
logger = logging.getLogger(name='chapeau')
|
|
|
|
######################
|
|
|
|
class KepiManager(PolymorphicManager):
|
|
|
|
# TODO: This should allow filtering on names
|
|
# without their f_... prefixes, and also
|
|
# transparently on ThingFields.
|
|
|
|
def filter_local_only(self, *args, **kwargs):
|
|
self._adjust_kwargs_for_local_only(kwargs)
|
|
return self.filter(*args, **kwargs)
|
|
|
|
def get_local_only(self, *args, **kwargs):
|
|
self._adjust_kwargs_for_local_only(kwargs)
|
|
return self.get(*args, **kwargs)
|
|
|
|
def _adjust_kwargs_for_local_only(self, kwargs):
|
|
|
|
LOCAL_ONLY = 'id__regex'
|
|
|
|
if LOCAL_ONLY in kwargs:
|
|
raise ValueError(('%s is already an argument; '+\
|
|
'this should never happen') % (LOCAL_ONLY,))
|
|
kwargs[LOCAL_ONLY] = r'^[/@]'
|
|
|
|
######################
|
|
|
|
class AcObject(PolymorphicModel):
|
|
|
|
id = models.CharField(
|
|
max_length=255,
|
|
primary_key=True,
|
|
unique=True,
|
|
blank=False,
|
|
default=None,
|
|
editable=False,
|
|
)
|
|
|
|
objects = KepiManager()
|
|
|
|
published = models.DateTimeField(
|
|
default = timezone.now,
|
|
)
|
|
|
|
updated = models.DateTimeField(
|
|
auto_now = True,
|
|
)
|
|
|
|
@property
|
|
def url(self):
|
|
uri = self.uri
|
|
|
|
if uri is None:
|
|
return self.id
|
|
else:
|
|
return uri_to_url(uri)
|
|
|
|
@property
|
|
def uri(self):
|
|
if self.id.startswith('/'):
|
|
return configured_path('OBJECT_LINK',
|
|
number = self.id[1:],
|
|
)
|
|
elif self.id.startswith('@'):
|
|
return configured_path('USER_LINK',
|
|
username = self.id[1:],
|
|
)
|
|
else:
|
|
return None
|
|
|
|
@property
|
|
def number(self):
|
|
if self.is_local:
|
|
return self.id[1:]
|
|
else:
|
|
return None
|
|
|
|
def __str__(self):
|
|
|
|
result = '[%s %s]' % (
|
|
self.id,
|
|
self.f_type,
|
|
)
|
|
|
|
return result
|
|
|
|
@property
|
|
def short_id(self):
|
|
return self.id
|
|
|
|
@property
|
|
def pretty(self):
|
|
result = ''
|
|
curly = '{'
|
|
|
|
items = [
|
|
('type', self.f_type),
|
|
]
|
|
|
|
for f, v in sorted(self.activity_form.items()):
|
|
|
|
if f in ['type']:
|
|
continue
|
|
|
|
items.append( (f,v) )
|
|
|
|
items.extend( [
|
|
('actor', self.f_actor),
|
|
] )
|
|
|
|
for f, v in items:
|
|
|
|
if not v:
|
|
continue
|
|
|
|
if result:
|
|
result += ',\n'
|
|
|
|
result += '%1s %15s: %s' % (
|
|
curly,
|
|
f,
|
|
v,
|
|
)
|
|
curly = ''
|
|
|
|
result += ' }'
|
|
|
|
return result
|
|
|
|
@property
|
|
def f_type(self):
|
|
return self.__class__.__name__[2:]
|
|
|
|
@property
|
|
def activity_form(self):
|
|
|
|
from chapeau.bowler_pub.utils import short_id_to_url
|
|
|
|
result = {
|
|
'id': self.url,
|
|
'type': self.f_type,
|
|
'published': self.published,
|
|
}
|
|
|
|
for name in dir(self):
|
|
if not name.startswith('f_'):
|
|
continue
|
|
|
|
value = getattr(self, name)
|
|
value = short_id_to_url(value)
|
|
|
|
if not isinstance(value, str):
|
|
continue
|
|
|
|
if value=='':
|
|
value = None
|
|
|
|
result[name[2:]] = value
|
|
|
|
result.update(ThingField.get_fields_for(self))
|
|
result.update(Audience.get_audiences_for(self))
|
|
|
|
return result
|
|
|
|
def __contains__(self, name):
|
|
try:
|
|
self.__getitem__(name)
|
|
return True
|
|
except:
|
|
return False
|
|
|
|
def __getitem__(self, name):
|
|
|
|
from chapeau.bowler_pub.find import find
|
|
|
|
name_parts = name.split('__')
|
|
name = name_parts.pop(0)
|
|
|
|
if hasattr(self, 'f_'+name):
|
|
result = getattr(self, 'f_'+name)
|
|
elif name in [
|
|
'published',
|
|
'updated',
|
|
'url',
|
|
'id',
|
|
]:
|
|
result = getattr(self, name)
|
|
elif name in AUDIENCE_FIELD_NAMES:
|
|
try:
|
|
result = Audience.get_audiences_for(
|
|
thing = self,
|
|
audience_type = name,
|
|
)
|
|
except Audience.DoesNotExist:
|
|
result = None
|
|
else:
|
|
try:
|
|
another = ThingField.objects.get(
|
|
parent = self,
|
|
field = name)
|
|
|
|
if 'raw' in name_parts:
|
|
result = another.value
|
|
else:
|
|
result = another.interpreted_value
|
|
|
|
except ThingField.DoesNotExist:
|
|
result = None
|
|
|
|
if 'find' in name_parts and result is not None:
|
|
result = find(result,
|
|
do_not_fetch=True)
|
|
elif 'obj' in name_parts and result is not None:
|
|
result = AcObject.get_by_url(url=result)
|
|
|
|
return result
|
|
|
|
def __setitem__(self, name, value):
|
|
|
|
value = _normalise_type_for_thing(value)
|
|
|
|
logger.debug(' -- %8s %12s %s',
|
|
self.id,
|
|
name,
|
|
value,
|
|
)
|
|
|
|
if hasattr(self, 'f_'+name):
|
|
setattr(self, 'f_'+name, value)
|
|
self.save()
|
|
elif name in [
|
|
'published',
|
|
]:
|
|
setattr(self, name, value)
|
|
self.save()
|
|
elif name in AUDIENCE_FIELD_NAMES:
|
|
|
|
if self.pk is None:
|
|
# We *must* save at this point;
|
|
# otherwise Audience might have no foreign key.
|
|
self.save()
|
|
|
|
Audience.add_audiences_for(
|
|
thing = self,
|
|
field = name,
|
|
value = value,
|
|
)
|
|
else:
|
|
|
|
from chapeau.bowler_pub.utils import as_json
|
|
|
|
if self.pk is None:
|
|
# See above
|
|
self.save()
|
|
|
|
try:
|
|
another = ThingField.objects.get(
|
|
parent = self,
|
|
field = name,
|
|
)
|
|
except ThingField.DoesNotExist:
|
|
another = ThingField(
|
|
parent = self,
|
|
field = name,
|
|
)
|
|
|
|
|
|
another.value = as_json(value)
|
|
another.save()
|
|
|
|
# Special-cased side effects:
|
|
|
|
if name=='tag':
|
|
|
|
# We must save, in order for Mention's fk to point to us
|
|
self.save()
|
|
|
|
Mention.set_from_tags(
|
|
status=self,
|
|
tags=value,
|
|
)
|
|
|
|
@property
|
|
def audiences(self):
|
|
return Audience.get_audiences_for(self)
|
|
|
|
def run_side_effects(self):
|
|
|
|
from chapeau.bowler_pub.find import find
|
|
from chapeau.bowler_pub.delivery import deliver
|
|
|
|
f_type = self.f_type.lower()
|
|
|
|
if not hasattr(side_effects, f_type):
|
|
logger.debug(' -- no side effects for %s',
|
|
f_type)
|
|
return True
|
|
|
|
result = getattr(side_effects, f_type)(self)
|
|
|
|
return result
|
|
|
|
@property
|
|
def is_local(self):
|
|
return self.id and self.id[0] in '@/'
|
|
|
|
def entomb(self):
|
|
logger.info('%s: entombing', self)
|
|
|
|
if self['former_type'] is not None:
|
|
logger.warn(' -- already entombed; ignoring')
|
|
return
|
|
|
|
if not self.is_local:
|
|
raise ValueError("%s: you can't entomb remote things",
|
|
self)
|
|
|
|
self['former_type'] = self.f_type
|
|
self['deleted'] = timezone.now()
|
|
|
|
self.save()
|
|
logger.info('%s: entombed', self)
|
|
|
|
def _generate_id(self):
|
|
"""
|
|
Returns a value for "id" on a new object, where
|
|
the caller has omitted to supply an "id" value.
|
|
The new value should be unique.
|
|
|
|
If this method returns None, the object will
|
|
not be created.
|
|
"""
|
|
return '/%08x' % (random.randint(0, 0xffffffff),)
|
|
|
|
def _check_provided_id(self):
|
|
"""
|
|
Checks self.id to see whether it's valid for
|
|
this kind of AcObject. It may normalise the value.
|
|
|
|
If the value is valid, returns.
|
|
If the value is invalid, raises ValueError.
|
|
|
|
This method is not called if self.id is a valid
|
|
URL, because that means it's a remote object
|
|
and our naming rules won't apply.
|
|
"""
|
|
if re.match(SERIAL_NUMBER_REGEXP, self.id,
|
|
re.IGNORECASE):
|
|
|
|
self.id = self.id.lower()
|
|
logger.debug('id==%s which is a valid serial number',
|
|
self.id)
|
|
return
|
|
|
|
raise ValueError("Object IDs begin with a slash "+\
|
|
"followed by eight characters from "+\
|
|
"0-9 or a-f. "+\
|
|
"You gave: "+self.id)
|
|
|
|
def save(self, *args, **kwargs):
|
|
|
|
if self.id is None:
|
|
self.id = self._generate_id()
|
|
|
|
if self.id is None:
|
|
raise ValueError("You need to specify an id "+\
|
|
"on %s objects." % (self.__class__.__name__,))
|
|
else:
|
|
if re.match(URL_REGEXP, self.id,
|
|
re.IGNORECASE):
|
|
logger.debug('id==%s which is a valid URL',
|
|
self.id)
|
|
else:
|
|
self._check_provided_id()
|
|
|
|
try:
|
|
super().save(*args, **kwargs)
|
|
logger.debug('%s: saved', self)
|
|
except IntegrityError as ie:
|
|
logger.info('Integrity error on save (%s); failed',
|
|
ie)
|
|
raise ie
|
|
|
|
@classmethod
|
|
def get_by_url(cls, url):
|
|
"""
|
|
Retrieves an AcObject whose URL is "url".
|
|
|
|
This differs from find() in that it can
|
|
find objects which were submitted to us over HTTP
|
|
(as opposed to generated locally or fetched by us).
|
|
find() would ignore these.
|
|
"""
|
|
|
|
from chapeau.bowler_pub.find import find
|
|
|
|
logger.debug(' -- finding object with url %s', url)
|
|
result = find(url,
|
|
local_only = True)
|
|
|
|
if result is None:
|
|
logger.debug(' -- not local; trying remote')
|
|
try:
|
|
result = cls.objects.get(
|
|
id = url,
|
|
)
|
|
except cls.DoesNotExist:
|
|
result = None
|
|
|
|
logger.debug(' -- found %s', result)
|
|
return result
|
|
|
|
######################################
|
|
|
|
def _normalise_type_for_thing(v):
|
|
if v is None:
|
|
return v # we're good with nulls
|
|
if isinstance(v, str):
|
|
return v # strings are fine
|
|
elif isinstance(v, dict):
|
|
return v # so are dicts
|
|
elif isinstance(v, bool):
|
|
return v # also booleans
|
|
elif isinstance(v, list):
|
|
return v # and lists as well
|
|
elif isinstance(v, AcObject):
|
|
return v.short_id # AcObjects can deal with themselves
|
|
|
|
# okay, it's something weird
|
|
|
|
try:
|
|
return v.activity_form
|
|
except AttributeError:
|
|
return str(v)
|
|
|
|
|