Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • 1.4.1-upgrade-release
  • 1121-download
  • 1218-smartplaylist_backend
  • 1373-login-form-move-reset-your-password-link
  • 1381-progress-bars
  • 1481
  • 1518-update-django-allauth
  • 1645
  • 1675-widget-improperly-configured-missing-resource-id
  • 1675-widget-improperly-configured-missing-resource-id-2
  • 1704-required-props-are-not-always-passed
  • 1716-add-frontend-tests-again
  • 1749-smtp-uri-configuration
  • 1930-first-upload-in-a-batch-always-fails
  • 1976-update-documentation-links-in-readme-files
  • 2054-player-layout
  • 2063-funkwhale-connection-interrupted-every-so-often-requires-network-reset-page-refresh
  • 2091-iii-6-improve-visuals-layout
  • 2151-refused-to-load-spa-manifest-json-2
  • 2154-add-to-playlist-pop-up-hidden-by-now-playing-screen
  • 2155-can-t-see-the-episode-list-of-a-podcast-as-an-anonymous-user-with-anonymous-access-enabled
  • 2156-add-management-command-to-change-file-ref-for-in-place-imported-files-to-s3
  • 2192-clear-queue-bug-when-random-shuffle-is-enabled
  • 2205-channel-page-pagination-link-dont-working
  • 2215-custom-logger-does-not-work-at-all-with-webkit-and-blink-based-browsers
  • 2228-troi-real-world-review
  • 2274-implement-new-upload-api
  • 2303-allow-users-to-own-tagged-items
  • 2395-far-right-filter
  • 2405-front-buttont-trigger-third-party-hook
  • 2408-troi-create-missing-tracks
  • 2416-revert-library-drop
  • 2422-trigger-libraries-follow-on-user-follow
  • 2429-fix-popover-auto-close
  • 2448-complete-tags
  • 2452-fetch-third-party-metadata
  • 2469-Fix-search-bar-in-ManageUploads
  • 2476-deep-upload-links
  • 2490-experiment-use-rstore
  • 2490-experimental-use-simple-data-store
  • 2490-fix-search-modal
  • 2490-search-modal
  • 2501-fix-compatibility-with-older-browsers
  • 2502-drop-uno-and-jquery
  • 2533-allow-followers-in-user-activiy-privacy-level
  • 2539-drop-ansible-installation-method-in-favor-of-docker
  • 2560-default-modal-width
  • 623-test
  • 653-enable-starting-embedded-player-at-a-specific-position-in-track
  • activitypub-overview
  • album-sliders
  • arne/2091-improve-visuals
  • back-option-for-edits
  • chore/2406-compose-modularity-scope
  • develop
  • develop-password-reset
  • env-file-cleanup
  • feat/2091-improve-visuals
  • feature/2481-vui-translations
  • fix-amd64-docker-build-gfortran
  • fix-front-node-version
  • fix-gitpod
  • fix-plugins-dev-setup
  • fix-rate-limit-serializer
  • fix-schema-channel-metadata-choices
  • flupsi/2803-improve-visuals
  • flupsi/2804-new-upload-process
  • funkwhale-fix_pwa_manifest
  • funkwhale-petitminion-2136-bug-fix-prune-skipped-upload
  • funkwhale-ui-buttons
  • georg/add-typescript
  • gitpod/test-1866
  • global-button-experiment
  • global-buttons
  • juniorjpdj/pkg-repo
  • manage-py-reference
  • merge-review
  • minimal-python-version
  • petitminion-develop-patch-84496
  • pin-mutagen-to-1.46
  • pipenv
  • plugins
  • plugins-v2
  • plugins-v3
  • pre-release/1.3.0
  • prune_skipped_uploads_docs
  • refactor/homepage
  • renovate/front-all-dependencies
  • renovate/front-major-all-dependencies
  • schema-updates
  • small-gitpod-improvements
  • spectacular_schema
  • stable
  • tempArne
  • ui-buttons
  • update-frontend-dependencies
  • upload-process-spec
  • user-concept-docs
  • v2-artists
  • vite-ws-ssl-compatible
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.17
  • 0.18
  • 0.18.1
  • 0.18.2
  • 0.18.3
  • 0.19.0
  • 0.19.0-rc1
  • 0.19.0-rc2
  • 0.19.1
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.20.0
  • 0.20.0-rc1
  • 0.20.1
  • 0.21
  • 0.21-rc1
  • 0.21-rc2
  • 0.21.1
  • 0.21.2
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
  • 1.0
  • 1.0-rc1
  • 1.0.1
  • 1.1
  • 1.1-rc1
  • 1.1-rc2
  • 1.1.1
  • 1.1.2
  • 1.1.3
  • 1.1.4
  • 1.2.0
  • 1.2.0-rc1
  • 1.2.0-rc2
  • 1.2.0-testing
  • 1.2.0-testing2
  • 1.2.0-testing3
  • 1.2.0-testing4
  • 1.2.1
  • 1.2.10
  • 1.2.2
  • 1.2.3
  • 1.2.4
  • 1.2.5
  • 1.2.6
  • 1.2.6-1
  • 1.2.7
  • 1.2.8
  • 1.2.9
  • 1.3.0
  • 1.3.0-rc1
  • 1.3.0-rc2
  • 1.3.0-rc3
  • 1.3.0-rc4
  • 1.3.0-rc5
  • 1.3.0-rc6
  • 1.3.1
  • 1.3.2
  • 1.3.3
  • 1.3.4
  • 1.4.0
  • 1.4.0-rc1
  • 1.4.0-rc2
  • 1.4.1
  • 2.0.0-alpha.1
  • 2.0.0-alpha.2
200 results

Target

Select target project
  • funkwhale/funkwhale
  • Luclu7/funkwhale
  • mbothorel/funkwhale
  • EorlBruder/funkwhale
  • tcit/funkwhale
  • JocelynDelalande/funkwhale
  • eneiluj/funkwhale
  • reg/funkwhale
  • ButterflyOfFire/funkwhale
  • m4sk1n/funkwhale
  • wxcafe/funkwhale
  • andybalaam/funkwhale
  • jcgruenhage/funkwhale
  • pblayo/funkwhale
  • joshuaboniface/funkwhale
  • n3ddy/funkwhale
  • gegeweb/funkwhale
  • tohojo/funkwhale
  • emillumine/funkwhale
  • Te-k/funkwhale
  • asaintgenis/funkwhale
  • anoadragon453/funkwhale
  • Sakada/funkwhale
  • ilianaw/funkwhale
  • l4p1n/funkwhale
  • pnizet/funkwhale
  • dante383/funkwhale
  • interfect/funkwhale
  • akhardya/funkwhale
  • svfusion/funkwhale
  • noplanman/funkwhale
  • nykopol/funkwhale
  • roipoussiere/funkwhale
  • Von/funkwhale
  • aurieh/funkwhale
  • icaria36/funkwhale
  • floreal/funkwhale
  • paulwalko/funkwhale
  • comradekingu/funkwhale
  • FurryJulie/funkwhale
  • Legolars99/funkwhale
  • Vierkantor/funkwhale
  • zachhats/funkwhale
  • heyjake/funkwhale
  • sn0w/funkwhale
  • jvoisin/funkwhale
  • gordon/funkwhale
  • Alexander/funkwhale
  • bignose/funkwhale
  • qasim.ali/funkwhale
  • fakegit/funkwhale
  • Kxze/funkwhale
  • stenstad/funkwhale
  • creak/funkwhale
  • Kaze/funkwhale
  • Tixie/funkwhale
  • IISergII/funkwhale
  • lfuelling/funkwhale
  • nhaddag/funkwhale
  • yoasif/funkwhale
  • ifischer/funkwhale
  • keslerm/funkwhale
  • flupe/funkwhale
  • petitminion/funkwhale
  • ariasuni/funkwhale
  • ollie/funkwhale
  • ngaumont/funkwhale
  • techknowlogick/funkwhale
  • Shleeble/funkwhale
  • theflyingfrog/funkwhale
  • jonatron/funkwhale
  • neobrain/funkwhale
  • eorn/funkwhale
  • KokaKiwi/funkwhale
  • u1-liquid/funkwhale
  • marzzzello/funkwhale
  • sirenwatcher/funkwhale
  • newer027/funkwhale
  • codl/funkwhale
  • Zwordi/funkwhale
  • gisforgabriel/funkwhale
  • iuriatan/funkwhale
  • simon/funkwhale
  • bheesham/funkwhale
  • zeoses/funkwhale
  • accraze/funkwhale
  • meliurwen/funkwhale
  • divadsn/funkwhale
  • Etua/funkwhale
  • sdrik/funkwhale
  • Soran/funkwhale
  • kuba-orlik/funkwhale
  • cristianvogel/funkwhale
  • Forceu/funkwhale
  • jeff/funkwhale
  • der_scheibenhacker/funkwhale
  • owlnical/funkwhale
  • jovuit/funkwhale
  • SilverFox15/funkwhale
  • phw/funkwhale
  • mayhem/funkwhale
  • sridhar/funkwhale
  • stromlin/funkwhale
  • rrrnld/funkwhale
  • nitaibezerra/funkwhale
  • jaller94/funkwhale
  • pcouy/funkwhale
  • eduxstad/funkwhale
  • codingHahn/funkwhale
  • captain/funkwhale
  • polyedre/funkwhale
  • leishenailong/funkwhale
  • ccritter/funkwhale
  • lnceballosz/funkwhale
  • fpiesche/funkwhale
  • Fanyx/funkwhale
  • markusblogde/funkwhale
  • Firobe/funkwhale
  • devilcius/funkwhale
  • freaktechnik/funkwhale
  • blopware/funkwhale
  • cone/funkwhale
  • thanksd/funkwhale
  • vachan-maker/funkwhale
  • bbenti/funkwhale
  • tarator/funkwhale
  • prplecake/funkwhale
  • DMarzal/funkwhale
  • lullis/funkwhale
  • hanacgr/funkwhale
  • albjeremias/funkwhale
  • xeruf/funkwhale
  • llelite/funkwhale
  • RoiArthurB/funkwhale
  • cloo/funkwhale
  • nztvar/funkwhale
  • Keunes/funkwhale
  • petitminion/funkwhale-petitminion
  • m-idler/funkwhale
  • SkyLeite/funkwhale
140 results
Select Git revision
  • 170-federation
  • 594-navigation-redesign
  • 735-table-truncate
  • 839-donation-link
  • 865-sql-optimization
  • 890-notification
  • 925-flac-transcoding
  • add-new-shortcuts
  • develop
  • landing-page
  • limit-album-tracks
  • live-streaming
  • master
  • ollie/funkwhale-documentation-fixes
  • plugins
  • plugins-v2
  • vuln-testing
  • webdav
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.17
  • 0.18
  • 0.18.1
  • 0.18.2
  • 0.18.3
  • 0.19.0
  • 0.19.0-rc1
  • 0.19.0-rc2
  • 0.19.1
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.20.0
  • 0.20.0-rc1
  • 0.20.1
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
68 results
Show changes
Showing
with 6081 additions and 479 deletions
# Generated by Django 5.1.6 on 2025-08-04 13:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("federation", "0028_auto_20221027_1141"),
("music", "0061_migrate_libraries_to_playlist"),
]
operations = [
migrations.AddField(
model_name="domain",
name="reachable_retries",
field=models.PositiveIntegerField(default=0),
),
]
......@@ -3,16 +3,16 @@ import urllib.parse
import uuid
from django.conf import settings
from django.contrib.postgres.fields import JSONField
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.db.models.signals import post_save, pre_save, post_delete
from django.db.models import JSONField
from django.db.models.signals import post_delete, post_save, pre_save
from django.dispatch import receiver
from django.utils import timezone
from django.urls import reverse
from django.utils import timezone
from funkwhale_api.common import session
from funkwhale_api.common import utils as common_utils
......@@ -30,6 +30,10 @@ TYPE_CHOICES = [
("Service", "Service"),
]
MAX_LENGTHS = {
"ACTOR_NAME": 200,
}
def empty_dict():
return {}
......@@ -48,7 +52,7 @@ class FederationMixin(models.Model):
abstract = True
@property
def is_local(self):
def is_local(self) -> bool:
return federation_utils.is_local(self.fid)
@property
......@@ -76,7 +80,7 @@ class ActorQuerySet(models.QuerySet):
)
qs = qs.annotate(
**{
"_usage_{}".format(s): models.Sum(
f"_usage_{s}": models.Sum(
"libraries__uploads__size", filter=uploads_query
)
}
......@@ -123,7 +127,9 @@ class Domain(models.Model):
)
# are interactions with this domain allowed (only applies when allow-listing is on)
allowed = models.BooleanField(default=None, null=True)
reachable = models.BooleanField(default=True)
last_successful_contact = models.DateTimeField(default=None, null=True)
reachable_retries = models.PositiveIntegerField(default=0)
objects = DomainQuerySet.as_manager()
def __str__(self):
......@@ -142,15 +148,16 @@ class Domain(models.Model):
from funkwhale_api.music import models as music_models
data = Domain.objects.filter(pk=self.pk).aggregate(
actors=models.Count("actors", distinct=True),
outbox_activities=models.Count("actors__outbox_activities", distinct=True),
libraries=models.Count("actors__libraries", distinct=True),
channels=models.Count("actors__owned_channels", distinct=True),
received_library_follows=models.Count(
"actors__libraries__received_follows", distinct=True
),
emitted_library_follows=models.Count(
"actors__library_follows", distinct=True
),
actors=models.Count("actors", distinct=True),
)
data["artists"] = music_models.Artist.objects.filter(
from_activity__actor__domain_id=self.pk
......@@ -171,7 +178,7 @@ class Domain(models.Model):
return data
@property
def is_local(self):
def is_local(self) -> bool:
return self.name == settings.FEDERATION_HOSTNAME
......@@ -180,21 +187,24 @@ class Actor(models.Model):
fid = models.URLField(unique=True, max_length=500, db_index=True)
url = models.URLField(max_length=500, null=True, blank=True)
outbox_url = models.URLField(max_length=500)
inbox_url = models.URLField(max_length=500)
outbox_url = models.URLField(max_length=500, null=True, blank=True)
inbox_url = models.URLField(max_length=500, null=True, blank=True)
following_url = models.URLField(max_length=500, null=True, blank=True)
followers_url = models.URLField(max_length=500, null=True, blank=True)
shared_inbox_url = models.URLField(max_length=500, null=True, blank=True)
type = models.CharField(choices=TYPE_CHOICES, default="Person", max_length=25)
name = models.CharField(max_length=200, null=True, blank=True)
name = models.CharField(max_length=MAX_LENGTHS["ACTOR_NAME"], null=True, blank=True)
domain = models.ForeignKey(Domain, on_delete=models.CASCADE, related_name="actors")
summary = models.CharField(max_length=500, null=True, blank=True)
summary_obj = models.ForeignKey(
"common.Content", null=True, blank=True, on_delete=models.SET_NULL
)
preferred_username = models.CharField(max_length=200, null=True, blank=True)
public_key = models.TextField(max_length=5000, null=True, blank=True)
private_key = models.TextField(max_length=5000, null=True, blank=True)
creation_date = models.DateTimeField(default=timezone.now)
last_fetch_date = models.DateTimeField(default=timezone.now)
manually_approves_followers = models.NullBooleanField(default=None)
manually_approves_followers = models.BooleanField(default=None, null=True)
followers = models.ManyToManyField(
to="self",
symmetrical=False,
......@@ -202,7 +212,13 @@ class Actor(models.Model):
through_fields=("target", "actor"),
related_name="following",
)
attachment_icon = models.ForeignKey(
"common.Attachment",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="iconed_actor",
)
objects = ActorQuerySet.as_manager()
class Meta:
......@@ -210,32 +226,40 @@ class Actor(models.Model):
verbose_name = "Account"
def get_moderation_url(self):
return "/manage/moderation/accounts/{}".format(self.full_username)
return f"/manage/moderation/accounts/{self.full_username}"
@property
def webfinger_subject(self):
return "{}@{}".format(self.preferred_username, settings.FEDERATION_HOSTNAME)
return f"{self.preferred_username}@{settings.FEDERATION_HOSTNAME}"
@property
def private_key_id(self):
return "{}#main-key".format(self.fid)
return f"{self.fid}#main-key"
@property
def full_username(self):
return "{}@{}".format(self.preferred_username, self.domain_id)
def full_username(self) -> str:
return f"{self.preferred_username}@{self.domain_id}"
def __str__(self):
return "{}@{}".format(self.preferred_username, self.domain_id)
return f"{self.preferred_username}@{self.domain_id}"
@property
def is_local(self):
def is_local(self) -> bool:
return self.domain_id == settings.FEDERATION_HOSTNAME
def get_approved_followers(self):
follows = self.received_follows.filter(approved=True)
return self.followers.filter(pk__in=follows.values_list("actor", flat=True))
def get_approved_followings(self):
follows = self.emitted_follows.filter(approved=True)
return Actor.objects.filter(pk__in=follows.values_list("target", flat=True))
def should_autoapprove_follow(self, actor):
if self.get_channel():
return True
if self.user.privacy_level == "public":
return True
return False
def get_user(self):
......@@ -244,31 +268,46 @@ class Actor(models.Model):
except ObjectDoesNotExist:
return None
def get_channel(self):
try:
return self.channel
except ObjectDoesNotExist:
return None
def get_absolute_url(self):
if self.is_local:
return federation_utils.full_url(f"/@{self.preferred_username}")
return self.url or self.fid
def get_current_usage(self):
actor = self.__class__.objects.filter(pk=self.pk).with_current_usage().get()
data = {}
for s in ["draft", "pending", "skipped", "errored", "finished"]:
data[s] = getattr(actor, "_usage_{}".format(s)) or 0
data[s] = getattr(actor, f"_usage_{s}") or 0
data["total"] = sum(data.values())
return data
def get_stats(self):
from funkwhale_api.music import models as music_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.music import models as music_models
data = Actor.objects.filter(pk=self.pk).aggregate(
outbox_activities=models.Count("outbox_activities", distinct=True),
libraries=models.Count("libraries", distinct=True),
channels=models.Count("owned_channels", distinct=True),
received_library_follows=models.Count(
"libraries__received_follows", distinct=True
),
emitted_library_follows=models.Count("library_follows", distinct=True),
libraries=models.Count("libraries", distinct=True),
)
data["artists"] = music_models.Artist.objects.filter(
from_activity__actor=self.pk
).count()
data["reports"] = moderation_models.Report.objects.get_for_target(self).count()
data["requests"] = moderation_models.UserRequest.objects.filter(
submitter=self
).count()
data["albums"] = music_models.Album.objects.filter(
from_activity__actor=self.pk
).count()
......@@ -308,10 +347,14 @@ class Actor(models.Model):
# matches, we consider the actor has the permission to manage
# the object
domain = self.domain_id
return obj.fid.startswith("http://{}/".format(domain)) or obj.fid.startswith(
"https://{}/".format(domain)
return obj.fid.startswith(f"http://{domain}/") or obj.fid.startswith(
f"https://{domain}/"
)
@property
def display_name(self):
return self.name or self.preferred_username
FETCH_STATUSES = [
("pending", "Pending"),
......@@ -345,22 +388,30 @@ class Fetch(models.Model):
objects = FetchQuerySet.as_manager()
def save(self, **kwargs):
if not self.url and self.object:
if not self.url and self.object and hasattr(self.object, "fid"):
self.url = self.object.fid
super().save(**kwargs)
@property
def serializers(self):
from . import contexts
from . import serializers
from . import contexts, serializers
return {
contexts.FW.Artist: serializers.ArtistSerializer,
contexts.FW.Album: serializers.AlbumSerializer,
contexts.FW.Track: serializers.TrackSerializer,
contexts.AS.Audio: serializers.UploadSerializer,
contexts.FW.Library: serializers.LibrarySerializer,
contexts.FW.Artist: [serializers.ArtistSerializer],
contexts.FW.Album: [serializers.AlbumSerializer],
contexts.FW.Track: [serializers.TrackSerializer],
contexts.AS.Audio: [
serializers.UploadSerializer,
serializers.ChannelUploadSerializer,
],
contexts.FW.Library: [serializers.LibrarySerializer],
contexts.FW.Playlist: [serializers.PlaylistSerializer],
contexts.AS.Group: [serializers.ActorSerializer],
contexts.AS.Person: [serializers.ActorSerializer],
contexts.AS.Organization: [serializers.ActorSerializer],
contexts.AS.Service: [serializers.ActorSerializer],
contexts.AS.Application: [serializers.ActorSerializer],
}
......@@ -411,26 +462,29 @@ class Activity(models.Model):
type = models.CharField(db_index=True, null=True, max_length=100)
# generic relations
object_id = models.IntegerField(null=True)
object_id = models.IntegerField(null=True, blank=True)
object_content_type = models.ForeignKey(
ContentType,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="objecting_activities",
)
object = GenericForeignKey("object_content_type", "object_id")
target_id = models.IntegerField(null=True)
target_id = models.IntegerField(null=True, blank=True)
target_content_type = models.ForeignKey(
ContentType,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="targeting_activities",
)
target = GenericForeignKey("target_content_type", "target_id")
related_object_id = models.IntegerField(null=True)
related_object_id = models.IntegerField(null=True, blank=True)
related_object_content_type = models.ForeignKey(
ContentType,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="related_objecting_activities",
)
......@@ -445,15 +499,13 @@ class AbstractFollow(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now)
modification_date = models.DateTimeField(auto_now=True)
approved = models.NullBooleanField(default=None)
approved = models.BooleanField(default=None, null=True)
class Meta:
abstract = True
def get_federation_id(self):
return federation_utils.full_url(
"{}#follows/{}".format(self.actor.fid, self.uuid)
)
return federation_utils.full_url(f"{self.actor.fid}#follows/{self.uuid}")
class Follow(AbstractFollow):
......@@ -541,13 +593,13 @@ class LibraryTrack(models.Model):
auth=auth,
stream=True,
timeout=20,
headers={"Content-Type": "application/activity+json"},
headers={"Accept": "application/activity+json"},
)
with remote_response as r:
remote_response.raise_for_status()
extension = music_utils.get_ext_from_type(self.audio_mimetype)
title = " - ".join([self.title, self.album_title, self.artist_name])
filename = "{}.{}".format(title, extension)
filename = f"{title}.{extension}"
tmp_file = tempfile.TemporaryFile()
for chunk in r.iter_content(chunk_size=512):
tmp_file.write(chunk)
......
from funkwhale_api.moderation import mrf
from . import activity
......
from rest_framework.negotiation import BaseContentNegotiation
from rest_framework.renderers import JSONRenderer
......@@ -6,6 +7,7 @@ def get_ap_renderers():
("APActivity", "application/activity+json"),
("APLD", "application/ld+json"),
("APJSON", "application/json"),
("HTML", "text/html"),
]
return [
......@@ -14,5 +16,19 @@ def get_ap_renderers():
]
class IgnoreClientContentNegotiation(BaseContentNegotiation):
def select_parser(self, request, parsers):
"""
Select the first parser in the `.parser_classes` list.
"""
return parsers[0]
def select_renderer(self, request, renderers, format_suffix):
"""
Select the first renderer in the `.renderer_classes` list.
"""
return (renderers[0], renderers[0].media_type)
class WebfingerRenderer(JSONRenderer):
media_type = "application/jrd+json"
import logging
import uuid
from django.db.models import Q
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.history import models as history_models
from funkwhale_api.music import models as music_models
from funkwhale_api.playlists import models as playlist_models
from . import activity
from . import actors
from . import models
from . import serializers
from . import activity, actors, models, serializers
logger = logging.getLogger(__name__)
inbox = activity.InboxRouter()
......@@ -79,6 +82,37 @@ def outbox_accept(context):
}
@outbox.register({"type": "Reject"})
def outbox_reject_follow(context):
follow = context["follow"]
if follow._meta.label == "federation.LibraryFollow":
actor = follow.target.actor
else:
actor = follow.target
payload = serializers.RejectFollowSerializer(follow, context={"actor": actor}).data
yield {
"actor": actor,
"type": "Reject",
"payload": with_recipients(payload, to=[follow.actor]),
"object": follow,
"related_object": follow.target,
}
@inbox.register({"type": "Reject"})
def inbox_reject_follow(payload, context):
serializer = serializers.RejectFollowSerializer(data=payload, context=context)
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
logger.debug(
"Discarding invalid follow reject from %s: %s",
context["actor"].fid,
serializer.errors,
)
return
serializer.save()
@inbox.register({"type": "Undo", "object.type": "Follow"})
def inbox_undo_follow(payload, context):
serializer = serializers.UndoFollowSerializer(data=payload, context=context)
......@@ -131,37 +165,53 @@ def outbox_follow(context):
@outbox.register({"type": "Create", "object.type": "Audio"})
def outbox_create_audio(context):
upload = context["upload"]
channel = upload.library.get_channel()
followers_target = channel.actor if channel else upload.library.actor
actor = channel.actor if channel else upload.library.actor
if channel:
serializer = serializers.ChannelCreateUploadSerializer(upload)
else:
upload_serializer = serializers.UploadSerializer
serializer = serializers.ActivitySerializer(
{
"type": "Create",
"actor": upload.library.actor.fid,
"object": serializers.UploadSerializer(upload).data,
"actor": actor.fid,
"object": upload_serializer(upload).data,
}
)
yield {
"type": "Create",
"actor": upload.library.actor,
"actor": actor,
"payload": with_recipients(
serializer.data, to=[{"type": "followers", "target": upload.library}]
serializer.data, to=[{"type": "followers", "target": followers_target}]
),
"object": upload,
"target": upload.library,
"target": None if channel else upload.library,
}
@inbox.register({"type": "Create", "object.type": "Audio"})
def inbox_create_audio(payload, context):
is_channel = "library" not in payload["object"]
if is_channel:
channel = context["actor"].get_channel()
serializer = serializers.ChannelCreateUploadSerializer(
data=payload,
context={"channel": channel},
)
else:
serializer = serializers.UploadSerializer(
data=payload["object"],
context={"activity": context.get("activity"), "actor": context["actor"]},
)
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
logger.warn("Discarding invalid audio create")
logger.warn("Discarding invalid audio create: %s", serializer.errors)
return
upload = serializer.save()
if is_channel:
return {"object": upload, "target": channel}
else:
return {"object": upload, "target": upload.library}
......@@ -245,9 +295,10 @@ def inbox_delete_audio(payload, context):
# we did not receive a list of Ids, so we can probably use the value directly
upload_fids = [payload["object"]["id"]]
candidates = music_models.Upload.objects.filter(
library__actor=actor, fid__in=upload_fids
query = Q(fid__in=upload_fids) & (
Q(library__actor=actor) | Q(track__artist_credit__artist__channel__actor=actor)
)
candidates = music_models.Upload.objects.filter(query)
total = candidates.count()
logger.info("Deleting %s uploads with ids %s", total, upload_fids)
......@@ -258,6 +309,9 @@ def inbox_delete_audio(payload, context):
def outbox_delete_audio(context):
uploads = context["uploads"]
library = uploads[0].library
channel = library.get_channel()
actor = channel.actor if channel else library.actor
followers_target = channel.actor if channel else actor
serializer = serializers.ActivitySerializer(
{
"type": "Delete",
......@@ -266,9 +320,9 @@ def outbox_delete_audio(context):
)
yield {
"type": "Delete",
"actor": library.actor,
"actor": actor,
"payload": with_recipients(
serializer.data, to=[{"type": "followers", "target": library}]
serializer.data, to=[{"type": "followers", "target": followers_target}]
),
}
......@@ -312,6 +366,37 @@ def inbox_update_track(payload, context):
)
@inbox.register({"type": "Update", "object.type": "Audio"})
def inbox_update_audio(payload, context):
serializer = serializers.ChannelCreateUploadSerializer(
data=payload, context=context
)
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
logger.info("Skipped update, invalid payload")
return
serializer.save()
@outbox.register({"type": "Update", "object.type": "Audio"})
def outbox_update_audio(context):
upload = context["upload"]
channel = upload.library.get_channel()
actor = channel.actor
serializer = serializers.ChannelCreateUploadSerializer(
upload, context={"type": "Update", "activity_id_suffix": str(uuid.uuid4())[:8]}
)
yield {
"type": "Update",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@inbox.register({"type": "Update", "object.type": "Artist"})
def inbox_update_artist(payload, context):
return handle_library_entry_update(
......@@ -416,7 +501,6 @@ def outbox_delete_actor(context):
{
"type": "Delete",
"object.type": [
"Tombstone",
"Actor",
"Person",
"Application",
......@@ -441,3 +525,320 @@ def inbox_delete_actor(payload, context):
logger.warn("Cannot delete actor %s, no matching object found", actor.fid)
return
actor.delete()
@inbox.register({"type": "Delete", "object.type": "Tombstone"})
def inbox_delete(payload, context):
serializer = serializers.DeleteSerializer(data=payload, context=context)
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
logger.info("Skipped deletion, invalid payload")
return
to_delete = serializer.validated_data["object"]
to_delete.delete()
@inbox.register({"type": "Flag"})
def inbox_flag(payload, context):
serializer = serializers.FlagSerializer(data=payload, context=context)
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
logger.debug(
"Discarding invalid report from {}: %s",
context["actor"].fid,
serializer.errors,
)
return
report = serializer.save()
return {"object": report.target, "related_object": report}
@outbox.register({"type": "Flag"})
def outbox_flag(context):
report = context["report"]
if not report.target or not report.target.fid:
return
actor = actors.get_service_actor()
serializer = serializers.FlagSerializer(report)
yield {
"type": "Flag",
"actor": actor,
"payload": with_recipients(
serializer.data,
# Mastodon requires the report to be sent to the reported actor inbox
# (and not the shared inbox)
to=[{"type": "actor_inbox", "actor": report.target_owner}],
),
}
@inbox.register({"type": "Delete", "object.type": "Album"})
def inbox_delete_album(payload, context):
actor = context["actor"]
album_id = payload["object"].get("id")
if not album_id:
logger.debug("Discarding deletion of empty library")
return
query = Q(fid=album_id) & (
Q(attributed_to=actor) | Q(artist_credit__artist__channel__actor=actor)
)
try:
album = music_models.Album.objects.get(query)
except music_models.Album.DoesNotExist:
logger.debug("Discarding deletion of unkwnown album %s", album_id)
return
album.delete()
@outbox.register({"type": "Delete", "object.type": "Album"})
def outbox_delete_album(context):
album = context["album"]
album_artist = album.artist_credit.all()[0].artist
actor = (
album_artist.channel.actor
if album_artist.get_channel()
else album.attributed_to
)
actor = actor or actors.get_service_actor()
serializer = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Album", "id": album.fid}}
)
yield {
"type": "Delete",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@outbox.register({"type": "Like", "object.type": "Track"})
def outbox_create_track_favorite(context):
track = context["track"]
actor = context["actor"]
serializer = serializers.ActivitySerializer(
{
"type": "Like",
"id": context["id"],
"object": {"type": "Track", "id": track.fid},
"audience": actor.user.privacy_level,
}
)
yield {
"type": "Like",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@outbox.register({"type": "Dislike", "object.type": "Track"})
def outbox_delete_favorite(context):
favorite = context["favorite"]
actor = favorite.actor
serializer = serializers.ActivitySerializer(
{"type": "Dislike", "object": {"type": "Track", "id": favorite.track.fid}}
)
yield {
"type": "Dislike",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@inbox.register({"type": "Like", "object.type": "Track"})
def inbox_create_favorite(payload, context):
serializer = serializers.TrackFavoriteSerializer(data=payload)
serializer.is_valid(raise_exception=True)
instance = serializer.save()
return {"object": instance}
@inbox.register({"type": "Dislike", "object.type": "Track"})
def inbox_delete_favorite(payload, context):
actor = context["actor"]
track_id = payload["object"].get("id")
query = Q(track__fid=track_id) & Q(actor=actor)
try:
favorite = favorites_models.TrackFavorite.objects.get(query)
except favorites_models.TrackFavorite.DoesNotExist:
logger.debug(
"Discarding deletion of unkwnown favorite with track : %s", track_id
)
return
favorite.delete()
@outbox.register({"type": "Listen", "object.type": "Track"})
def outbox_create_listening(context):
track = context["track"]
actor = context["actor"]
serializer = serializers.ActivitySerializer(
{
"type": "Listen",
"id": context["id"],
"object": {"type": "Track", "id": track.fid},
"audience": actor.user.privacy_level,
}
)
yield {
"type": "Listen",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@outbox.register({"type": "Delete", "object.type": "Listen"})
def outbox_delete_listening(context):
listening = context["listening"]
actor = listening.actor
serializer = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Listen", "id": listening.fid}}
)
yield {
"type": "Delete",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@inbox.register({"type": "Listen", "object.type": "Track"})
def inbox_create_listening(payload, context):
serializer = serializers.ListeningSerializer(data=payload)
serializer.is_valid(raise_exception=True)
instance = serializer.save()
return {"object": instance}
@inbox.register({"type": "Delete", "object.type": "Listen"})
def inbox_delete_listening(payload, context):
actor = context["actor"]
listening_id = payload["object"].get("id")
query = Q(fid=listening_id) & Q(actor=actor)
try:
favorite = history_models.Listening.objects.get(query)
except history_models.Listening.DoesNotExist:
logger.debug("Discarding deletion of unkwnown listening %s", listening_id)
return
favorite.delete()
@outbox.register({"type": "Create", "object.type": "Playlist"})
def outbox_create_playlist(context):
playlist = context["playlist"]
serializer = serializers.ActivitySerializer(
{
"type": "Create",
"actor": playlist.actor,
"id": playlist.fid,
"object": serializers.PlaylistSerializer(playlist).data,
}
)
yield {
"type": "Create",
"actor": playlist.actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": playlist.actor}],
),
}
@outbox.register({"type": "Delete", "object.type": "Playlist"})
def outbox_delete_playlist(context):
playlist = context["playlist"]
actor = playlist.actor
serializer = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Playlist", "id": playlist.fid}}
)
yield {
"type": "Delete",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@inbox.register({"type": "Create", "object.type": "Playlist"})
def inbox_create_playlist(payload, context):
serializer = serializers.PlaylistSerializer(data=payload["object"])
serializer.is_valid(raise_exception=True)
instance = serializer.save()
return {"object": instance}
@inbox.register({"type": "Delete", "object.type": "Playlist"})
def inbox_delete_playlist(payload, context):
actor = context["actor"]
playlist_id = payload["object"].get("id")
query = Q(fid=playlist_id) & Q(actor=actor)
try:
playlist = playlist_models.Playlist.objects.get(query)
except playlist_models.Playlist.DoesNotExist:
logger.debug("Discarding deletion of unkwnown listening %s", playlist_id)
return
playlist.playlist_tracks.all().delete()
playlist.delete()
@inbox.register({"type": "Update", "object.type": "Playlist"})
def inbox_update_playlist(payload, context):
"""If we receive an update on an unkwnown playlist, we create the playlist"""
playlist_id = payload["object"].get("id")
serializer = serializers.PlaylistSerializer(data=payload["object"])
if serializer.is_valid(raise_exception=True):
playlist = serializer.save()
# we update the playlist.library to get the plt.track.uploads locally
if follows := playlist.library.received_follows.filter(approved=True):
playlist.library.schedule_scan(follows[0].actor, force=True)
# we trigger a scan since we use this activity to avoid sending many PlaylistTracks activities
playlist.schedule_scan(actors.get_service_actor(), force=True)
return
else:
logger.debug(
"Discarding update of playlist_id %s because of payload errors: %s",
playlist_id,
serializer.errors,
)
@outbox.register({"type": "Update", "object.type": "Playlist"})
def outbox_update_playlist(context):
playlist = context["playlist"]
serializer = serializers.ActivitySerializer(
{"type": "Update", "object": serializers.PlaylistSerializer(playlist).data}
)
yield {
"type": "Update",
"actor": playlist.actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": playlist.actor}],
),
}
CONTEXT = {
"type": "@type",
"id": "@id",
"HTML": {"@id": "rdf:HTML"},
"@vocab": "http://schema.org/",
"xml": "http://www.w3.org/XML/1998/namespace",
"foaf": "http://xmlns.com/foaf/0.1/",
"eli": "http://data.europa.eu/eli/ontology#",
"snomed": "http://purl.bioontology.org/ontology/SNOMEDCT/",
"bibo": "http://purl.org/ontology/bibo/",
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
"skos": "http://www.w3.org/2004/02/skos/core#",
"void": "http://rdfs.org/ns/void#",
"dc": "http://purl.org/dc/elements/1.1/",
"dctype": "http://purl.org/dc/dcmitype/",
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
"dcat": "http://www.w3.org/ns/dcat#",
"rdfa": "http://www.w3.org/ns/rdfa#",
"xsd": "http://www.w3.org/2001/XMLSchema#",
"schema": "http://schema.org/",
"dct": "http://purl.org/dc/terms/",
"dcterms": "http://purl.org/dc/terms/",
"owl": "http://www.w3.org/2002/07/owl#",
"3DModel": {"@id": "schema:3DModel"},
"AMRadioChannel": {"@id": "schema:AMRadioChannel"},
"APIReference": {"@id": "schema:APIReference"},
"Abdomen": {"@id": "schema:Abdomen"},
"AboutPage": {"@id": "schema:AboutPage"},
"AcceptAction": {"@id": "schema:AcceptAction"},
"Accommodation": {"@id": "schema:Accommodation"},
"AccountingService": {"@id": "schema:AccountingService"},
"AchieveAction": {"@id": "schema:AchieveAction"},
"Action": {"@id": "schema:Action"},
"ActionAccessSpecification": {"@id": "schema:ActionAccessSpecification"},
"ActionStatusType": {"@id": "schema:ActionStatusType"},
"ActivateAction": {"@id": "schema:ActivateAction"},
"ActiveActionStatus": {"@id": "schema:ActiveActionStatus"},
"ActiveNotRecruiting": {"@id": "schema:ActiveNotRecruiting"},
"AddAction": {"@id": "schema:AddAction"},
"AdministrativeArea": {"@id": "schema:AdministrativeArea"},
"AdultEntertainment": {"@id": "schema:AdultEntertainment"},
"AdvertiserContentArticle": {"@id": "schema:AdvertiserContentArticle"},
"AerobicActivity": {"@id": "schema:AerobicActivity"},
"AggregateOffer": {"@id": "schema:AggregateOffer"},
"AggregateRating": {"@id": "schema:AggregateRating"},
"AgreeAction": {"@id": "schema:AgreeAction"},
"Airline": {"@id": "schema:Airline"},
"Airport": {"@id": "schema:Airport"},
"AlbumRelease": {"@id": "schema:AlbumRelease"},
"AlignmentObject": {"@id": "schema:AlignmentObject"},
"AllWheelDriveConfiguration": {"@id": "schema:AllWheelDriveConfiguration"},
"AllocateAction": {"@id": "schema:AllocateAction"},
"AmusementPark": {"@id": "schema:AmusementPark"},
"AnaerobicActivity": {"@id": "schema:AnaerobicActivity"},
"AnalysisNewsArticle": {"@id": "schema:AnalysisNewsArticle"},
"AnatomicalStructure": {"@id": "schema:AnatomicalStructure"},
"AnatomicalSystem": {"@id": "schema:AnatomicalSystem"},
"Anesthesia": {"@id": "schema:Anesthesia"},
"AnimalShelter": {"@id": "schema:AnimalShelter"},
"Answer": {"@id": "schema:Answer"},
"Apartment": {"@id": "schema:Apartment"},
"ApartmentComplex": {"@id": "schema:ApartmentComplex"},
"Appearance": {"@id": "schema:Appearance"},
"AppendAction": {"@id": "schema:AppendAction"},
"ApplyAction": {"@id": "schema:ApplyAction"},
"ApprovedIndication": {"@id": "schema:ApprovedIndication"},
"Aquarium": {"@id": "schema:Aquarium"},
"ArchiveComponent": {"@id": "schema:ArchiveComponent"},
"ArchiveOrganization": {"@id": "schema:ArchiveOrganization"},
"ArriveAction": {"@id": "schema:ArriveAction"},
"ArtGallery": {"@id": "schema:ArtGallery"},
"Artery": {"@id": "schema:Artery"},
"Article": {"@id": "schema:Article"},
"AskAction": {"@id": "schema:AskAction"},
"AskPublicNewsArticle": {"@id": "schema:AskPublicNewsArticle"},
"AssessAction": {"@id": "schema:AssessAction"},
"AssignAction": {"@id": "schema:AssignAction"},
"Atlas": {"@id": "schema:Atlas"},
"Attorney": {"@id": "schema:Attorney"},
"Audience": {"@id": "schema:Audience"},
"AudioObject": {"@id": "schema:AudioObject"},
"Audiobook": {"@id": "schema:Audiobook"},
"AudiobookFormat": {"@id": "schema:AudiobookFormat"},
"AuthenticContent": {"@id": "schema:AuthenticContent"},
"AuthoritativeLegalValue": {"@id": "schema:AuthoritativeLegalValue"},
"AuthorizeAction": {"@id": "schema:AuthorizeAction"},
"AutoBodyShop": {"@id": "schema:AutoBodyShop"},
"AutoDealer": {"@id": "schema:AutoDealer"},
"AutoPartsStore": {"@id": "schema:AutoPartsStore"},
"AutoRental": {"@id": "schema:AutoRental"},
"AutoRepair": {"@id": "schema:AutoRepair"},
"AutoWash": {"@id": "schema:AutoWash"},
"AutomatedTeller": {"@id": "schema:AutomatedTeller"},
"AutomotiveBusiness": {"@id": "schema:AutomotiveBusiness"},
"Ayurvedic": {"@id": "schema:Ayurvedic"},
"BackgroundNewsArticle": {"@id": "schema:BackgroundNewsArticle"},
"Bacteria": {"@id": "schema:Bacteria"},
"Bakery": {"@id": "schema:Bakery"},
"Balance": {"@id": "schema:Balance"},
"BankAccount": {"@id": "schema:BankAccount"},
"BankOrCreditUnion": {"@id": "schema:BankOrCreditUnion"},
"BarOrPub": {"@id": "schema:BarOrPub"},
"Barcode": {"@id": "schema:Barcode"},
"Beach": {"@id": "schema:Beach"},
"BeautySalon": {"@id": "schema:BeautySalon"},
"BedAndBreakfast": {"@id": "schema:BedAndBreakfast"},
"BedDetails": {"@id": "schema:BedDetails"},
"BedType": {"@id": "schema:BedType"},
"BefriendAction": {"@id": "schema:BefriendAction"},
"BenefitsHealthAspect": {"@id": "schema:BenefitsHealthAspect"},
"BikeStore": {"@id": "schema:BikeStore"},
"Blog": {"@id": "schema:Blog"},
"BlogPosting": {"@id": "schema:BlogPosting"},
"BloodTest": {"@id": "schema:BloodTest"},
"BoardingPolicyType": {"@id": "schema:BoardingPolicyType"},
"BodyOfWater": {"@id": "schema:BodyOfWater"},
"Bone": {"@id": "schema:Bone"},
"Book": {"@id": "schema:Book"},
"BookFormatType": {"@id": "schema:BookFormatType"},
"BookSeries": {"@id": "schema:BookSeries"},
"BookStore": {"@id": "schema:BookStore"},
"BookmarkAction": {"@id": "schema:BookmarkAction"},
"Boolean": {"@id": "schema:Boolean"},
"BorrowAction": {"@id": "schema:BorrowAction"},
"BowlingAlley": {"@id": "schema:BowlingAlley"},
"BrainStructure": {"@id": "schema:BrainStructure"},
"Brand": {"@id": "schema:Brand"},
"BreadcrumbList": {"@id": "schema:BreadcrumbList"},
"Brewery": {"@id": "schema:Brewery"},
"Bridge": {"@id": "schema:Bridge"},
"BroadcastChannel": {"@id": "schema:BroadcastChannel"},
"BroadcastEvent": {"@id": "schema:BroadcastEvent"},
"BroadcastFrequencySpecification": {
"@id": "schema:BroadcastFrequencySpecification"
},
"BroadcastRelease": {"@id": "schema:BroadcastRelease"},
"BroadcastService": {"@id": "schema:BroadcastService"},
"BrokerageAccount": {"@id": "schema:BrokerageAccount"},
"BuddhistTemple": {"@id": "schema:BuddhistTemple"},
"BusOrCoach": {"@id": "schema:BusOrCoach"},
"BusReservation": {"@id": "schema:BusReservation"},
"BusStation": {"@id": "schema:BusStation"},
"BusStop": {"@id": "schema:BusStop"},
"BusTrip": {"@id": "schema:BusTrip"},
"BusinessAudience": {"@id": "schema:BusinessAudience"},
"BusinessEntityType": {"@id": "schema:BusinessEntityType"},
"BusinessEvent": {"@id": "schema:BusinessEvent"},
"BusinessFunction": {"@id": "schema:BusinessFunction"},
"BuyAction": {"@id": "schema:BuyAction"},
"CDFormat": {"@id": "schema:CDFormat"},
"CT": {"@id": "schema:CT"},
"CableOrSatelliteService": {"@id": "schema:CableOrSatelliteService"},
"CafeOrCoffeeShop": {"@id": "schema:CafeOrCoffeeShop"},
"Campground": {"@id": "schema:Campground"},
"CampingPitch": {"@id": "schema:CampingPitch"},
"Canal": {"@id": "schema:Canal"},
"CancelAction": {"@id": "schema:CancelAction"},
"Car": {"@id": "schema:Car"},
"CarUsageType": {"@id": "schema:CarUsageType"},
"Cardiovascular": {"@id": "schema:Cardiovascular"},
"CardiovascularExam": {"@id": "schema:CardiovascularExam"},
"CaseSeries": {"@id": "schema:CaseSeries"},
"Casino": {"@id": "schema:Casino"},
"CassetteFormat": {"@id": "schema:CassetteFormat"},
"CategoryCode": {"@id": "schema:CategoryCode"},
"CategoryCodeSet": {"@id": "schema:CategoryCodeSet"},
"CatholicChurch": {"@id": "schema:CatholicChurch"},
"CausesHealthAspect": {"@id": "schema:CausesHealthAspect"},
"Cemetery": {"@id": "schema:Cemetery"},
"Chapter": {"@id": "schema:Chapter"},
"CheckAction": {"@id": "schema:CheckAction"},
"CheckInAction": {"@id": "schema:CheckInAction"},
"CheckOutAction": {"@id": "schema:CheckOutAction"},
"CheckoutPage": {"@id": "schema:CheckoutPage"},
"ChildCare": {"@id": "schema:ChildCare"},
"ChildrensEvent": {"@id": "schema:ChildrensEvent"},
"Chiropractic": {"@id": "schema:Chiropractic"},
"ChooseAction": {"@id": "schema:ChooseAction"},
"Church": {"@id": "schema:Church"},
"City": {"@id": "schema:City"},
"CityHall": {"@id": "schema:CityHall"},
"CivicStructure": {"@id": "schema:CivicStructure"},
"Claim": {"@id": "schema:Claim"},
"ClaimReview": {"@id": "schema:ClaimReview"},
"Class": {"@id": "schema:Class"},
"Clinician": {"@id": "schema:Clinician"},
"Clip": {"@id": "schema:Clip"},
"ClothingStore": {"@id": "schema:ClothingStore"},
"CoOp": {"@id": "schema:CoOp"},
"Code": {"@id": "schema:Code"},
"CohortStudy": {"@id": "schema:CohortStudy"},
"Collection": {"@id": "schema:Collection"},
"CollectionPage": {"@id": "schema:CollectionPage"},
"CollegeOrUniversity": {"@id": "schema:CollegeOrUniversity"},
"ComedyClub": {"@id": "schema:ComedyClub"},
"ComedyEvent": {"@id": "schema:ComedyEvent"},
"ComicCoverArt": {"@id": "schema:ComicCoverArt"},
"ComicIssue": {"@id": "schema:ComicIssue"},
"ComicSeries": {"@id": "schema:ComicSeries"},
"ComicStory": {"@id": "schema:ComicStory"},
"Comment": {"@id": "schema:Comment"},
"CommentAction": {"@id": "schema:CommentAction"},
"CommentPermission": {"@id": "schema:CommentPermission"},
"CommunicateAction": {"@id": "schema:CommunicateAction"},
"CommunityHealth": {"@id": "schema:CommunityHealth"},
"CompilationAlbum": {"@id": "schema:CompilationAlbum"},
"CompleteDataFeed": {"@id": "schema:CompleteDataFeed"},
"Completed": {"@id": "schema:Completed"},
"CompletedActionStatus": {"@id": "schema:CompletedActionStatus"},
"CompoundPriceSpecification": {"@id": "schema:CompoundPriceSpecification"},
"ComputerLanguage": {"@id": "schema:ComputerLanguage"},
"ComputerStore": {"@id": "schema:ComputerStore"},
"ConfirmAction": {"@id": "schema:ConfirmAction"},
"Consortium": {"@id": "schema:Consortium"},
"ConsumeAction": {"@id": "schema:ConsumeAction"},
"ContactPage": {"@id": "schema:ContactPage"},
"ContactPoint": {"@id": "schema:ContactPoint"},
"ContactPointOption": {"@id": "schema:ContactPointOption"},
"ContagiousnessHealthAspect": {"@id": "schema:ContagiousnessHealthAspect"},
"Continent": {"@id": "schema:Continent"},
"ControlAction": {"@id": "schema:ControlAction"},
"ConvenienceStore": {"@id": "schema:ConvenienceStore"},
"Conversation": {"@id": "schema:Conversation"},
"CookAction": {"@id": "schema:CookAction"},
"Corporation": {"@id": "schema:Corporation"},
"CorrectionComment": {"@id": "schema:CorrectionComment"},
"Country": {"@id": "schema:Country"},
"Course": {"@id": "schema:Course"},
"CourseInstance": {"@id": "schema:CourseInstance"},
"Courthouse": {"@id": "schema:Courthouse"},
"CoverArt": {"@id": "schema:CoverArt"},
"CovidTestingFacility": {"@id": "schema:CovidTestingFacility"},
"CreateAction": {"@id": "schema:CreateAction"},
"CreativeWork": {"@id": "schema:CreativeWork"},
"CreativeWorkSeason": {"@id": "schema:CreativeWorkSeason"},
"CreativeWorkSeries": {"@id": "schema:CreativeWorkSeries"},
"CreditCard": {"@id": "schema:CreditCard"},
"Crematorium": {"@id": "schema:Crematorium"},
"CriticReview": {"@id": "schema:CriticReview"},
"CrossSectional": {"@id": "schema:CrossSectional"},
"CssSelectorType": {"@id": "schema:CssSelectorType"},
"CurrencyConversionService": {"@id": "schema:CurrencyConversionService"},
"DDxElement": {"@id": "schema:DDxElement"},
"DJMixAlbum": {"@id": "schema:DJMixAlbum"},
"DVDFormat": {"@id": "schema:DVDFormat"},
"DamagedCondition": {"@id": "schema:DamagedCondition"},
"DanceEvent": {"@id": "schema:DanceEvent"},
"DanceGroup": {"@id": "schema:DanceGroup"},
"DataCatalog": {"@id": "schema:DataCatalog"},
"DataDownload": {"@id": "schema:DataDownload"},
"DataFeed": {"@id": "schema:DataFeed"},
"DataFeedItem": {"@id": "schema:DataFeedItem"},
"DataType": {"@id": "schema:DataType"},
"Dataset": {"@id": "schema:Dataset"},
"Date": {"@id": "schema:Date"},
"DateTime": {"@id": "schema:DateTime"},
"DatedMoneySpecification": {"@id": "schema:DatedMoneySpecification"},
"DayOfWeek": {"@id": "schema:DayOfWeek"},
"DaySpa": {"@id": "schema:DaySpa"},
"DeactivateAction": {"@id": "schema:DeactivateAction"},
"DefenceEstablishment": {"@id": "schema:DefenceEstablishment"},
"DefinedTerm": {"@id": "schema:DefinedTerm"},
"DefinedTermSet": {"@id": "schema:DefinedTermSet"},
"DefinitiveLegalValue": {"@id": "schema:DefinitiveLegalValue"},
"DeleteAction": {"@id": "schema:DeleteAction"},
"DeliveryChargeSpecification": {"@id": "schema:DeliveryChargeSpecification"},
"DeliveryEvent": {"@id": "schema:DeliveryEvent"},
"DeliveryMethod": {"@id": "schema:DeliveryMethod"},
"Demand": {"@id": "schema:Demand"},
"DemoAlbum": {"@id": "schema:DemoAlbum"},
"Dentist": {"@id": "schema:Dentist"},
"Dentistry": {"@id": "schema:Dentistry"},
"DepartAction": {"@id": "schema:DepartAction"},
"DepartmentStore": {"@id": "schema:DepartmentStore"},
"DepositAccount": {"@id": "schema:DepositAccount"},
"Dermatologic": {"@id": "schema:Dermatologic"},
"Dermatology": {"@id": "schema:Dermatology"},
"DiabeticDiet": {"@id": "schema:DiabeticDiet"},
"Diagnostic": {"@id": "schema:Diagnostic"},
"DiagnosticLab": {"@id": "schema:DiagnosticLab"},
"DiagnosticProcedure": {"@id": "schema:DiagnosticProcedure"},
"Diet": {"@id": "schema:Diet"},
"DietNutrition": {"@id": "schema:DietNutrition"},
"DietarySupplement": {"@id": "schema:DietarySupplement"},
"DigitalAudioTapeFormat": {"@id": "schema:DigitalAudioTapeFormat"},
"DigitalDocument": {"@id": "schema:DigitalDocument"},
"DigitalDocumentPermission": {"@id": "schema:DigitalDocumentPermission"},
"DigitalDocumentPermissionType": {"@id": "schema:DigitalDocumentPermissionType"},
"DigitalFormat": {"@id": "schema:DigitalFormat"},
"DisagreeAction": {"@id": "schema:DisagreeAction"},
"Discontinued": {"@id": "schema:Discontinued"},
"DiscoverAction": {"@id": "schema:DiscoverAction"},
"DiscussionForumPosting": {"@id": "schema:DiscussionForumPosting"},
"DislikeAction": {"@id": "schema:DislikeAction"},
"Distance": {"@id": "schema:Distance"},
"Distillery": {"@id": "schema:Distillery"},
"DonateAction": {"@id": "schema:DonateAction"},
"DoseSchedule": {"@id": "schema:DoseSchedule"},
"DoubleBlindedTrial": {"@id": "schema:DoubleBlindedTrial"},
"DownloadAction": {"@id": "schema:DownloadAction"},
"DrawAction": {"@id": "schema:DrawAction"},
"Drawing": {"@id": "schema:Drawing"},
"DrinkAction": {"@id": "schema:DrinkAction"},
"DriveWheelConfigurationValue": {"@id": "schema:DriveWheelConfigurationValue"},
"DrivingSchoolVehicleUsage": {"@id": "schema:DrivingSchoolVehicleUsage"},
"Drug": {"@id": "schema:Drug"},
"DrugClass": {"@id": "schema:DrugClass"},
"DrugCost": {"@id": "schema:DrugCost"},
"DrugCostCategory": {"@id": "schema:DrugCostCategory"},
"DrugLegalStatus": {"@id": "schema:DrugLegalStatus"},
"DrugPregnancyCategory": {"@id": "schema:DrugPregnancyCategory"},
"DrugPrescriptionStatus": {"@id": "schema:DrugPrescriptionStatus"},
"DrugStrength": {"@id": "schema:DrugStrength"},
"DryCleaningOrLaundry": {"@id": "schema:DryCleaningOrLaundry"},
"Duration": {"@id": "schema:Duration"},
"EBook": {"@id": "schema:EBook"},
"EPRelease": {"@id": "schema:EPRelease"},
"Ear": {"@id": "schema:Ear"},
"EatAction": {"@id": "schema:EatAction"},
"EducationEvent": {"@id": "schema:EducationEvent"},
"EducationalAudience": {"@id": "schema:EducationalAudience"},
"EducationalOccupationalCredential": {
"@id": "schema:EducationalOccupationalCredential"
},
"EducationalOccupationalProgram": {"@id": "schema:EducationalOccupationalProgram"},
"EducationalOrganization": {"@id": "schema:EducationalOrganization"},
"Electrician": {"@id": "schema:Electrician"},
"ElectronicsStore": {"@id": "schema:ElectronicsStore"},
"ElementarySchool": {"@id": "schema:ElementarySchool"},
"EmailMessage": {"@id": "schema:EmailMessage"},
"Embassy": {"@id": "schema:Embassy"},
"Emergency": {"@id": "schema:Emergency"},
"EmergencyService": {"@id": "schema:EmergencyService"},
"EmployeeRole": {"@id": "schema:EmployeeRole"},
"EmployerAggregateRating": {"@id": "schema:EmployerAggregateRating"},
"EmployerReview": {"@id": "schema:EmployerReview"},
"EmploymentAgency": {"@id": "schema:EmploymentAgency"},
"Endocrine": {"@id": "schema:Endocrine"},
"EndorseAction": {"@id": "schema:EndorseAction"},
"EndorsementRating": {"@id": "schema:EndorsementRating"},
"Energy": {"@id": "schema:Energy"},
"EngineSpecification": {"@id": "schema:EngineSpecification"},
"EnrollingByInvitation": {"@id": "schema:EnrollingByInvitation"},
"EntertainmentBusiness": {"@id": "schema:EntertainmentBusiness"},
"EntryPoint": {"@id": "schema:EntryPoint"},
"Enumeration": {"@id": "schema:Enumeration"},
"Episode": {"@id": "schema:Episode"},
"Event": {"@id": "schema:Event"},
"EventAttendanceModeEnumeration": {"@id": "schema:EventAttendanceModeEnumeration"},
"EventCancelled": {"@id": "schema:EventCancelled"},
"EventMovedOnline": {"@id": "schema:EventMovedOnline"},
"EventPostponed": {"@id": "schema:EventPostponed"},
"EventRescheduled": {"@id": "schema:EventRescheduled"},
"EventReservation": {"@id": "schema:EventReservation"},
"EventScheduled": {"@id": "schema:EventScheduled"},
"EventSeries": {"@id": "schema:EventSeries"},
"EventStatusType": {"@id": "schema:EventStatusType"},
"EventVenue": {"@id": "schema:EventVenue"},
"EvidenceLevelA": {"@id": "schema:EvidenceLevelA"},
"EvidenceLevelB": {"@id": "schema:EvidenceLevelB"},
"EvidenceLevelC": {"@id": "schema:EvidenceLevelC"},
"ExchangeRateSpecification": {"@id": "schema:ExchangeRateSpecification"},
"ExchangeRefund": {"@id": "schema:ExchangeRefund"},
"ExerciseAction": {"@id": "schema:ExerciseAction"},
"ExerciseGym": {"@id": "schema:ExerciseGym"},
"ExercisePlan": {"@id": "schema:ExercisePlan"},
"ExhibitionEvent": {"@id": "schema:ExhibitionEvent"},
"Eye": {"@id": "schema:Eye"},
"FAQPage": {"@id": "schema:FAQPage"},
"FDAcategoryA": {"@id": "schema:FDAcategoryA"},
"FDAcategoryB": {"@id": "schema:FDAcategoryB"},
"FDAcategoryC": {"@id": "schema:FDAcategoryC"},
"FDAcategoryD": {"@id": "schema:FDAcategoryD"},
"FDAcategoryX": {"@id": "schema:FDAcategoryX"},
"FDAnotEvaluated": {"@id": "schema:FDAnotEvaluated"},
"FMRadioChannel": {"@id": "schema:FMRadioChannel"},
"FailedActionStatus": {"@id": "schema:FailedActionStatus"},
"False": {"@id": "schema:False"},
"FastFoodRestaurant": {"@id": "schema:FastFoodRestaurant"},
"Female": {"@id": "schema:Female"},
"Festival": {"@id": "schema:Festival"},
"FilmAction": {"@id": "schema:FilmAction"},
"FinancialProduct": {"@id": "schema:FinancialProduct"},
"FinancialService": {"@id": "schema:FinancialService"},
"FindAction": {"@id": "schema:FindAction"},
"FireStation": {"@id": "schema:FireStation"},
"Flexibility": {"@id": "schema:Flexibility"},
"Flight": {"@id": "schema:Flight"},
"FlightReservation": {"@id": "schema:FlightReservation"},
"Float": {"@id": "schema:Float"},
"FloorPlan": {"@id": "schema:FloorPlan"},
"Florist": {"@id": "schema:Florist"},
"FollowAction": {"@id": "schema:FollowAction"},
"FoodEstablishment": {"@id": "schema:FoodEstablishment"},
"FoodEstablishmentReservation": {"@id": "schema:FoodEstablishmentReservation"},
"FoodEvent": {"@id": "schema:FoodEvent"},
"FoodService": {"@id": "schema:FoodService"},
"FourWheelDriveConfiguration": {"@id": "schema:FourWheelDriveConfiguration"},
"Friday": {"@id": "schema:Friday"},
"FrontWheelDriveConfiguration": {"@id": "schema:FrontWheelDriveConfiguration"},
"FullRefund": {"@id": "schema:FullRefund"},
"FundingAgency": {"@id": "schema:FundingAgency"},
"FundingScheme": {"@id": "schema:FundingScheme"},
"Fungus": {"@id": "schema:Fungus"},
"FurnitureStore": {"@id": "schema:FurnitureStore"},
"Game": {"@id": "schema:Game"},
"GamePlayMode": {"@id": "schema:GamePlayMode"},
"GameServer": {"@id": "schema:GameServer"},
"GameServerStatus": {"@id": "schema:GameServerStatus"},
"GardenStore": {"@id": "schema:GardenStore"},
"GasStation": {"@id": "schema:GasStation"},
"Gastroenterologic": {"@id": "schema:Gastroenterologic"},
"GatedResidenceCommunity": {"@id": "schema:GatedResidenceCommunity"},
"GenderType": {"@id": "schema:GenderType"},
"GeneralContractor": {"@id": "schema:GeneralContractor"},
"Genetic": {"@id": "schema:Genetic"},
"Genitourinary": {"@id": "schema:Genitourinary"},
"GeoCircle": {"@id": "schema:GeoCircle"},
"GeoCoordinates": {"@id": "schema:GeoCoordinates"},
"GeoShape": {"@id": "schema:GeoShape"},
"GeospatialGeometry": {"@id": "schema:GeospatialGeometry"},
"Geriatric": {"@id": "schema:Geriatric"},
"GiveAction": {"@id": "schema:GiveAction"},
"GlutenFreeDiet": {"@id": "schema:GlutenFreeDiet"},
"GolfCourse": {"@id": "schema:GolfCourse"},
"GovernmentBuilding": {"@id": "schema:GovernmentBuilding"},
"GovernmentOffice": {"@id": "schema:GovernmentOffice"},
"GovernmentOrganization": {"@id": "schema:GovernmentOrganization"},
"GovernmentPermit": {"@id": "schema:GovernmentPermit"},
"GovernmentService": {"@id": "schema:GovernmentService"},
"Grant": {"@id": "schema:Grant"},
"GraphicNovel": {"@id": "schema:GraphicNovel"},
"GroceryStore": {"@id": "schema:GroceryStore"},
"GroupBoardingPolicy": {"@id": "schema:GroupBoardingPolicy"},
"Guide": {"@id": "schema:Guide"},
"Gynecologic": {"@id": "schema:Gynecologic"},
"HVACBusiness": {"@id": "schema:HVACBusiness"},
"HairSalon": {"@id": "schema:HairSalon"},
"HalalDiet": {"@id": "schema:HalalDiet"},
"Hardcover": {"@id": "schema:Hardcover"},
"HardwareStore": {"@id": "schema:HardwareStore"},
"Head": {"@id": "schema:Head"},
"HealthAndBeautyBusiness": {"@id": "schema:HealthAndBeautyBusiness"},
"HealthAspectEnumeration": {"@id": "schema:HealthAspectEnumeration"},
"HealthClub": {"@id": "schema:HealthClub"},
"HealthInsurancePlan": {"@id": "schema:HealthInsurancePlan"},
"HealthPlanCostSharingSpecification": {
"@id": "schema:HealthPlanCostSharingSpecification"
},
"HealthPlanFormulary": {"@id": "schema:HealthPlanFormulary"},
"HealthPlanNetwork": {"@id": "schema:HealthPlanNetwork"},
"HealthTopicContent": {"@id": "schema:HealthTopicContent"},
"HearingImpairedSupported": {"@id": "schema:HearingImpairedSupported"},
"Hematologic": {"@id": "schema:Hematologic"},
"HighSchool": {"@id": "schema:HighSchool"},
"HinduDiet": {"@id": "schema:HinduDiet"},
"HinduTemple": {"@id": "schema:HinduTemple"},
"HobbyShop": {"@id": "schema:HobbyShop"},
"HomeAndConstructionBusiness": {"@id": "schema:HomeAndConstructionBusiness"},
"HomeGoodsStore": {"@id": "schema:HomeGoodsStore"},
"Homeopathic": {"@id": "schema:Homeopathic"},
"Hospital": {"@id": "schema:Hospital"},
"Hostel": {"@id": "schema:Hostel"},
"Hotel": {"@id": "schema:Hotel"},
"HotelRoom": {"@id": "schema:HotelRoom"},
"House": {"@id": "schema:House"},
"HousePainter": {"@id": "schema:HousePainter"},
"HowOrWhereHealthAspect": {"@id": "schema:HowOrWhereHealthAspect"},
"HowTo": {"@id": "schema:HowTo"},
"HowToDirection": {"@id": "schema:HowToDirection"},
"HowToItem": {"@id": "schema:HowToItem"},
"HowToSection": {"@id": "schema:HowToSection"},
"HowToStep": {"@id": "schema:HowToStep"},
"HowToSupply": {"@id": "schema:HowToSupply"},
"HowToTip": {"@id": "schema:HowToTip"},
"HowToTool": {"@id": "schema:HowToTool"},
"IceCreamShop": {"@id": "schema:IceCreamShop"},
"IgnoreAction": {"@id": "schema:IgnoreAction"},
"ImageGallery": {"@id": "schema:ImageGallery"},
"ImageObject": {"@id": "schema:ImageObject"},
"ImagingTest": {"@id": "schema:ImagingTest"},
"InForce": {"@id": "schema:InForce"},
"InStock": {"@id": "schema:InStock"},
"InStoreOnly": {"@id": "schema:InStoreOnly"},
"IndividualProduct": {"@id": "schema:IndividualProduct"},
"Infectious": {"@id": "schema:Infectious"},
"InfectiousAgentClass": {"@id": "schema:InfectiousAgentClass"},
"InfectiousDisease": {"@id": "schema:InfectiousDisease"},
"InformAction": {"@id": "schema:InformAction"},
"InsertAction": {"@id": "schema:InsertAction"},
"InstallAction": {"@id": "schema:InstallAction"},
"InsuranceAgency": {"@id": "schema:InsuranceAgency"},
"Intangible": {"@id": "schema:Intangible"},
"Integer": {"@id": "schema:Integer"},
"InteractAction": {"@id": "schema:InteractAction"},
"InteractionCounter": {"@id": "schema:InteractionCounter"},
"InternationalTrial": {"@id": "schema:InternationalTrial"},
"InternetCafe": {"@id": "schema:InternetCafe"},
"InvestmentFund": {"@id": "schema:InvestmentFund"},
"InvestmentOrDeposit": {"@id": "schema:InvestmentOrDeposit"},
"InviteAction": {"@id": "schema:InviteAction"},
"Invoice": {"@id": "schema:Invoice"},
"ItemAvailability": {"@id": "schema:ItemAvailability"},
"ItemList": {"@id": "schema:ItemList"},
"ItemListOrderAscending": {"@id": "schema:ItemListOrderAscending"},
"ItemListOrderDescending": {"@id": "schema:ItemListOrderDescending"},
"ItemListOrderType": {"@id": "schema:ItemListOrderType"},
"ItemListUnordered": {"@id": "schema:ItemListUnordered"},
"ItemPage": {"@id": "schema:ItemPage"},
"JewelryStore": {"@id": "schema:JewelryStore"},
"JobPosting": {"@id": "schema:JobPosting"},
"JoinAction": {"@id": "schema:JoinAction"},
"Joint": {"@id": "schema:Joint"},
"KosherDiet": {"@id": "schema:KosherDiet"},
"LaboratoryScience": {"@id": "schema:LaboratoryScience"},
"LakeBodyOfWater": {"@id": "schema:LakeBodyOfWater"},
"Landform": {"@id": "schema:Landform"},
"LandmarksOrHistoricalBuildings": {"@id": "schema:LandmarksOrHistoricalBuildings"},
"Language": {"@id": "schema:Language"},
"LaserDiscFormat": {"@id": "schema:LaserDiscFormat"},
"LeaveAction": {"@id": "schema:LeaveAction"},
"LeftHandDriving": {"@id": "schema:LeftHandDriving"},
"LegalForceStatus": {"@id": "schema:LegalForceStatus"},
"LegalService": {"@id": "schema:LegalService"},
"LegalValueLevel": {"@id": "schema:LegalValueLevel"},
"Legislation": {"@id": "schema:Legislation"},
"LegislationObject": {"@id": "schema:LegislationObject"},
"LegislativeBuilding": {"@id": "schema:LegislativeBuilding"},
"LeisureTimeActivity": {"@id": "schema:LeisureTimeActivity"},
"LendAction": {"@id": "schema:LendAction"},
"Library": {"@id": "schema:Library"},
"LibrarySystem": {"@id": "schema:LibrarySystem"},
"LifestyleModification": {"@id": "schema:LifestyleModification"},
"Ligament": {"@id": "schema:Ligament"},
"LikeAction": {"@id": "schema:LikeAction"},
"LimitedAvailability": {"@id": "schema:LimitedAvailability"},
"LinkRole": {"@id": "schema:LinkRole"},
"LiquorStore": {"@id": "schema:LiquorStore"},
"ListItem": {"@id": "schema:ListItem"},
"ListenAction": {"@id": "schema:ListenAction"},
"LiteraryEvent": {"@id": "schema:LiteraryEvent"},
"LiveAlbum": {"@id": "schema:LiveAlbum"},
"LiveBlogPosting": {"@id": "schema:LiveBlogPosting"},
"LivingWithHealthAspect": {"@id": "schema:LivingWithHealthAspect"},
"LoanOrCredit": {"@id": "schema:LoanOrCredit"},
"LocalBusiness": {"@id": "schema:LocalBusiness"},
"LocationFeatureSpecification": {"@id": "schema:LocationFeatureSpecification"},
"LockerDelivery": {"@id": "schema:LockerDelivery"},
"Locksmith": {"@id": "schema:Locksmith"},
"LodgingBusiness": {"@id": "schema:LodgingBusiness"},
"LodgingReservation": {"@id": "schema:LodgingReservation"},
"Longitudinal": {"@id": "schema:Longitudinal"},
"LoseAction": {"@id": "schema:LoseAction"},
"LowCalorieDiet": {"@id": "schema:LowCalorieDiet"},
"LowFatDiet": {"@id": "schema:LowFatDiet"},
"LowLactoseDiet": {"@id": "schema:LowLactoseDiet"},
"LowSaltDiet": {"@id": "schema:LowSaltDiet"},
"Lung": {"@id": "schema:Lung"},
"LymphaticVessel": {"@id": "schema:LymphaticVessel"},
"MRI": {"@id": "schema:MRI"},
"Male": {"@id": "schema:Male"},
"Manuscript": {"@id": "schema:Manuscript"},
"Map": {"@id": "schema:Map"},
"MapCategoryType": {"@id": "schema:MapCategoryType"},
"MarryAction": {"@id": "schema:MarryAction"},
"Mass": {"@id": "schema:Mass"},
"MaximumDoseSchedule": {"@id": "schema:MaximumDoseSchedule"},
"MayTreatHealthAspect": {"@id": "schema:MayTreatHealthAspect"},
"MediaGallery": {"@id": "schema:MediaGallery"},
"MediaManipulationRatingEnumeration": {
"@id": "schema:MediaManipulationRatingEnumeration"
},
"MediaObject": {"@id": "schema:MediaObject"},
"MediaReview": {"@id": "schema:MediaReview"},
"MediaSubscription": {"@id": "schema:MediaSubscription"},
"MedicalAudience": {"@id": "schema:MedicalAudience"},
"MedicalBusiness": {"@id": "schema:MedicalBusiness"},
"MedicalCause": {"@id": "schema:MedicalCause"},
"MedicalClinic": {"@id": "schema:MedicalClinic"},
"MedicalCode": {"@id": "schema:MedicalCode"},
"MedicalCondition": {"@id": "schema:MedicalCondition"},
"MedicalConditionStage": {"@id": "schema:MedicalConditionStage"},
"MedicalContraindication": {"@id": "schema:MedicalContraindication"},
"MedicalDevice": {"@id": "schema:MedicalDevice"},
"MedicalDevicePurpose": {"@id": "schema:MedicalDevicePurpose"},
"MedicalEntity": {"@id": "schema:MedicalEntity"},
"MedicalEnumeration": {"@id": "schema:MedicalEnumeration"},
"MedicalEvidenceLevel": {"@id": "schema:MedicalEvidenceLevel"},
"MedicalGuideline": {"@id": "schema:MedicalGuideline"},
"MedicalGuidelineContraindication": {
"@id": "schema:MedicalGuidelineContraindication"
},
"MedicalGuidelineRecommendation": {"@id": "schema:MedicalGuidelineRecommendation"},
"MedicalImagingTechnique": {"@id": "schema:MedicalImagingTechnique"},
"MedicalIndication": {"@id": "schema:MedicalIndication"},
"MedicalIntangible": {"@id": "schema:MedicalIntangible"},
"MedicalObservationalStudy": {"@id": "schema:MedicalObservationalStudy"},
"MedicalObservationalStudyDesign": {
"@id": "schema:MedicalObservationalStudyDesign"
},
"MedicalOrganization": {"@id": "schema:MedicalOrganization"},
"MedicalProcedure": {"@id": "schema:MedicalProcedure"},
"MedicalProcedureType": {"@id": "schema:MedicalProcedureType"},
"MedicalResearcher": {"@id": "schema:MedicalResearcher"},
"MedicalRiskCalculator": {"@id": "schema:MedicalRiskCalculator"},
"MedicalRiskEstimator": {"@id": "schema:MedicalRiskEstimator"},
"MedicalRiskFactor": {"@id": "schema:MedicalRiskFactor"},
"MedicalRiskScore": {"@id": "schema:MedicalRiskScore"},
"MedicalScholarlyArticle": {"@id": "schema:MedicalScholarlyArticle"},
"MedicalSign": {"@id": "schema:MedicalSign"},
"MedicalSignOrSymptom": {"@id": "schema:MedicalSignOrSymptom"},
"MedicalSpecialty": {"@id": "schema:MedicalSpecialty"},
"MedicalStudy": {"@id": "schema:MedicalStudy"},
"MedicalStudyStatus": {"@id": "schema:MedicalStudyStatus"},
"MedicalSymptom": {"@id": "schema:MedicalSymptom"},
"MedicalTest": {"@id": "schema:MedicalTest"},
"MedicalTestPanel": {"@id": "schema:MedicalTestPanel"},
"MedicalTherapy": {"@id": "schema:MedicalTherapy"},
"MedicalTrial": {"@id": "schema:MedicalTrial"},
"MedicalTrialDesign": {"@id": "schema:MedicalTrialDesign"},
"MedicalWebPage": {"@id": "schema:MedicalWebPage"},
"MedicineSystem": {"@id": "schema:MedicineSystem"},
"MeetingRoom": {"@id": "schema:MeetingRoom"},
"MensClothingStore": {"@id": "schema:MensClothingStore"},
"Menu": {"@id": "schema:Menu"},
"MenuItem": {"@id": "schema:MenuItem"},
"MenuSection": {"@id": "schema:MenuSection"},
"MerchantReturnEnumeration": {"@id": "schema:MerchantReturnEnumeration"},
"MerchantReturnFiniteReturnWindow": {
"@id": "schema:MerchantReturnFiniteReturnWindow"
},
"MerchantReturnNotPermitted": {"@id": "schema:MerchantReturnNotPermitted"},
"MerchantReturnPolicy": {"@id": "schema:MerchantReturnPolicy"},
"MerchantReturnUnlimitedWindow": {"@id": "schema:MerchantReturnUnlimitedWindow"},
"MerchantReturnUnspecified": {"@id": "schema:MerchantReturnUnspecified"},
"Message": {"@id": "schema:Message"},
"MiddleSchool": {"@id": "schema:MiddleSchool"},
"Midwifery": {"@id": "schema:Midwifery"},
"MisconceptionsHealthAspect": {"@id": "schema:MisconceptionsHealthAspect"},
"MissingContext": {"@id": "schema:MissingContext"},
"MixedEventAttendanceMode": {"@id": "schema:MixedEventAttendanceMode"},
"MixtapeAlbum": {"@id": "schema:MixtapeAlbum"},
"MobileApplication": {"@id": "schema:MobileApplication"},
"MobilePhoneStore": {"@id": "schema:MobilePhoneStore"},
"Monday": {"@id": "schema:Monday"},
"MonetaryAmount": {"@id": "schema:MonetaryAmount"},
"MonetaryAmountDistribution": {"@id": "schema:MonetaryAmountDistribution"},
"MonetaryGrant": {"@id": "schema:MonetaryGrant"},
"MoneyTransfer": {"@id": "schema:MoneyTransfer"},
"MortgageLoan": {"@id": "schema:MortgageLoan"},
"Mosque": {"@id": "schema:Mosque"},
"Motel": {"@id": "schema:Motel"},
"Motorcycle": {"@id": "schema:Motorcycle"},
"MotorcycleDealer": {"@id": "schema:MotorcycleDealer"},
"MotorcycleRepair": {"@id": "schema:MotorcycleRepair"},
"MotorizedBicycle": {"@id": "schema:MotorizedBicycle"},
"Mountain": {"@id": "schema:Mountain"},
"MoveAction": {"@id": "schema:MoveAction"},
"Movie": {"@id": "schema:Movie"},
"MovieClip": {"@id": "schema:MovieClip"},
"MovieRentalStore": {"@id": "schema:MovieRentalStore"},
"MovieSeries": {"@id": "schema:MovieSeries"},
"MovieTheater": {"@id": "schema:MovieTheater"},
"MovingCompany": {"@id": "schema:MovingCompany"},
"MultiCenterTrial": {"@id": "schema:MultiCenterTrial"},
"MultiPlayer": {"@id": "schema:MultiPlayer"},
"MulticellularParasite": {"@id": "schema:MulticellularParasite"},
"Muscle": {"@id": "schema:Muscle"},
"Musculoskeletal": {"@id": "schema:Musculoskeletal"},
"MusculoskeletalExam": {"@id": "schema:MusculoskeletalExam"},
"Museum": {"@id": "schema:Museum"},
"MusicAlbum": {"@id": "schema:MusicAlbum"},
"MusicAlbumProductionType": {"@id": "schema:MusicAlbumProductionType"},
"MusicAlbumReleaseType": {"@id": "schema:MusicAlbumReleaseType"},
"MusicComposition": {"@id": "schema:MusicComposition"},
"MusicEvent": {"@id": "schema:MusicEvent"},
"MusicGroup": {"@id": "schema:MusicGroup"},
"MusicPlaylist": {"@id": "schema:MusicPlaylist"},
"MusicRecording": {"@id": "schema:MusicRecording"},
"MusicRelease": {"@id": "schema:MusicRelease"},
"MusicReleaseFormatType": {"@id": "schema:MusicReleaseFormatType"},
"MusicStore": {"@id": "schema:MusicStore"},
"MusicVenue": {"@id": "schema:MusicVenue"},
"MusicVideoObject": {"@id": "schema:MusicVideoObject"},
"NGO": {"@id": "schema:NGO"},
"NailSalon": {"@id": "schema:NailSalon"},
"Neck": {"@id": "schema:Neck"},
"Nerve": {"@id": "schema:Nerve"},
"Neuro": {"@id": "schema:Neuro"},
"Neurologic": {"@id": "schema:Neurologic"},
"NewCondition": {"@id": "schema:NewCondition"},
"NewsArticle": {"@id": "schema:NewsArticle"},
"NewsMediaOrganization": {"@id": "schema:NewsMediaOrganization"},
"Newspaper": {"@id": "schema:Newspaper"},
"NightClub": {"@id": "schema:NightClub"},
"NoninvasiveProcedure": {"@id": "schema:NoninvasiveProcedure"},
"Nose": {"@id": "schema:Nose"},
"NotInForce": {"@id": "schema:NotInForce"},
"NotYetRecruiting": {"@id": "schema:NotYetRecruiting"},
"Notary": {"@id": "schema:Notary"},
"NoteDigitalDocument": {"@id": "schema:NoteDigitalDocument"},
"Number": {"@id": "schema:Number"},
"Nursing": {"@id": "schema:Nursing"},
"NutritionInformation": {"@id": "schema:NutritionInformation"},
"OTC": {"@id": "schema:OTC"},
"Observation": {"@id": "schema:Observation"},
"Observational": {"@id": "schema:Observational"},
"Obstetric": {"@id": "schema:Obstetric"},
"Occupation": {"@id": "schema:Occupation"},
"OccupationalActivity": {"@id": "schema:OccupationalActivity"},
"OccupationalTherapy": {"@id": "schema:OccupationalTherapy"},
"OceanBodyOfWater": {"@id": "schema:OceanBodyOfWater"},
"Offer": {"@id": "schema:Offer"},
"OfferCatalog": {"@id": "schema:OfferCatalog"},
"OfferForLease": {"@id": "schema:OfferForLease"},
"OfferForPurchase": {"@id": "schema:OfferForPurchase"},
"OfferItemCondition": {"@id": "schema:OfferItemCondition"},
"OfficeEquipmentStore": {"@id": "schema:OfficeEquipmentStore"},
"OfficialLegalValue": {"@id": "schema:OfficialLegalValue"},
"OfflineEventAttendanceMode": {"@id": "schema:OfflineEventAttendanceMode"},
"OfflinePermanently": {"@id": "schema:OfflinePermanently"},
"OfflineTemporarily": {"@id": "schema:OfflineTemporarily"},
"OnDemandEvent": {"@id": "schema:OnDemandEvent"},
"OnSitePickup": {"@id": "schema:OnSitePickup"},
"Oncologic": {"@id": "schema:Oncologic"},
"Online": {"@id": "schema:Online"},
"OnlineEventAttendanceMode": {"@id": "schema:OnlineEventAttendanceMode"},
"OnlineFull": {"@id": "schema:OnlineFull"},
"OnlineOnly": {"@id": "schema:OnlineOnly"},
"OpenTrial": {"@id": "schema:OpenTrial"},
"OpeningHoursSpecification": {"@id": "schema:OpeningHoursSpecification"},
"OpinionNewsArticle": {"@id": "schema:OpinionNewsArticle"},
"Optician": {"@id": "schema:Optician"},
"Optometric": {"@id": "schema:Optometric"},
"Order": {"@id": "schema:Order"},
"OrderAction": {"@id": "schema:OrderAction"},
"OrderCancelled": {"@id": "schema:OrderCancelled"},
"OrderDelivered": {"@id": "schema:OrderDelivered"},
"OrderInTransit": {"@id": "schema:OrderInTransit"},
"OrderItem": {"@id": "schema:OrderItem"},
"OrderPaymentDue": {"@id": "schema:OrderPaymentDue"},
"OrderPickupAvailable": {"@id": "schema:OrderPickupAvailable"},
"OrderProblem": {"@id": "schema:OrderProblem"},
"OrderProcessing": {"@id": "schema:OrderProcessing"},
"OrderReturned": {"@id": "schema:OrderReturned"},
"OrderStatus": {"@id": "schema:OrderStatus"},
"Organization": {"@id": "schema:Organization"},
"OrganizationRole": {"@id": "schema:OrganizationRole"},
"OrganizeAction": {"@id": "schema:OrganizeAction"},
"OriginalShippingFees": {"@id": "schema:OriginalShippingFees"},
"Osteopathic": {"@id": "schema:Osteopathic"},
"Otolaryngologic": {"@id": "schema:Otolaryngologic"},
"OutOfStock": {"@id": "schema:OutOfStock"},
"OutletStore": {"@id": "schema:OutletStore"},
"OverviewHealthAspect": {"@id": "schema:OverviewHealthAspect"},
"OwnershipInfo": {"@id": "schema:OwnershipInfo"},
"PET": {"@id": "schema:PET"},
"PaintAction": {"@id": "schema:PaintAction"},
"Painting": {"@id": "schema:Painting"},
"PalliativeProcedure": {"@id": "schema:PalliativeProcedure"},
"Paperback": {"@id": "schema:Paperback"},
"ParcelDelivery": {"@id": "schema:ParcelDelivery"},
"ParcelService": {"@id": "schema:ParcelService"},
"ParentAudience": {"@id": "schema:ParentAudience"},
"Park": {"@id": "schema:Park"},
"ParkingFacility": {"@id": "schema:ParkingFacility"},
"ParkingMap": {"@id": "schema:ParkingMap"},
"PartiallyInForce": {"@id": "schema:PartiallyInForce"},
"Pathology": {"@id": "schema:Pathology"},
"PathologyTest": {"@id": "schema:PathologyTest"},
"Patient": {"@id": "schema:Patient"},
"PatientExperienceHealthAspect": {"@id": "schema:PatientExperienceHealthAspect"},
"PawnShop": {"@id": "schema:PawnShop"},
"PayAction": {"@id": "schema:PayAction"},
"PaymentAutomaticallyApplied": {"@id": "schema:PaymentAutomaticallyApplied"},
"PaymentCard": {"@id": "schema:PaymentCard"},
"PaymentChargeSpecification": {"@id": "schema:PaymentChargeSpecification"},
"PaymentComplete": {"@id": "schema:PaymentComplete"},
"PaymentDeclined": {"@id": "schema:PaymentDeclined"},
"PaymentDue": {"@id": "schema:PaymentDue"},
"PaymentMethod": {"@id": "schema:PaymentMethod"},
"PaymentPastDue": {"@id": "schema:PaymentPastDue"},
"PaymentService": {"@id": "schema:PaymentService"},
"PaymentStatusType": {"@id": "schema:PaymentStatusType"},
"Pediatric": {"@id": "schema:Pediatric"},
"PeopleAudience": {"@id": "schema:PeopleAudience"},
"PercutaneousProcedure": {"@id": "schema:PercutaneousProcedure"},
"PerformAction": {"@id": "schema:PerformAction"},
"PerformanceRole": {"@id": "schema:PerformanceRole"},
"PerformingArtsTheater": {"@id": "schema:PerformingArtsTheater"},
"PerformingGroup": {"@id": "schema:PerformingGroup"},
"Periodical": {"@id": "schema:Periodical"},
"Permit": {"@id": "schema:Permit"},
"Person": {"@id": "schema:Person"},
"PetStore": {"@id": "schema:PetStore"},
"Pharmacy": {"@id": "schema:Pharmacy"},
"PharmacySpecialty": {"@id": "schema:PharmacySpecialty"},
"Photograph": {"@id": "schema:Photograph"},
"PhotographAction": {"@id": "schema:PhotographAction"},
"PhysicalActivity": {"@id": "schema:PhysicalActivity"},
"PhysicalActivityCategory": {"@id": "schema:PhysicalActivityCategory"},
"PhysicalExam": {"@id": "schema:PhysicalExam"},
"PhysicalTherapy": {"@id": "schema:PhysicalTherapy"},
"Physician": {"@id": "schema:Physician"},
"Physiotherapy": {"@id": "schema:Physiotherapy"},
"Place": {"@id": "schema:Place"},
"PlaceOfWorship": {"@id": "schema:PlaceOfWorship"},
"PlaceboControlledTrial": {"@id": "schema:PlaceboControlledTrial"},
"PlanAction": {"@id": "schema:PlanAction"},
"PlasticSurgery": {"@id": "schema:PlasticSurgery"},
"Play": {"@id": "schema:Play"},
"PlayAction": {"@id": "schema:PlayAction"},
"Playground": {"@id": "schema:Playground"},
"Plumber": {"@id": "schema:Plumber"},
"PodcastEpisode": {"@id": "schema:PodcastEpisode"},
"PodcastSeason": {"@id": "schema:PodcastSeason"},
"PodcastSeries": {"@id": "schema:PodcastSeries"},
"Podiatric": {"@id": "schema:Podiatric"},
"PoliceStation": {"@id": "schema:PoliceStation"},
"Pond": {"@id": "schema:Pond"},
"PostOffice": {"@id": "schema:PostOffice"},
"PostalAddress": {"@id": "schema:PostalAddress"},
"Poster": {"@id": "schema:Poster"},
"PotentialActionStatus": {"@id": "schema:PotentialActionStatus"},
"PreOrder": {"@id": "schema:PreOrder"},
"PreOrderAction": {"@id": "schema:PreOrderAction"},
"PreSale": {"@id": "schema:PreSale"},
"PrependAction": {"@id": "schema:PrependAction"},
"Preschool": {"@id": "schema:Preschool"},
"PrescriptionOnly": {"@id": "schema:PrescriptionOnly"},
"PresentationDigitalDocument": {"@id": "schema:PresentationDigitalDocument"},
"PreventionHealthAspect": {"@id": "schema:PreventionHealthAspect"},
"PreventionIndication": {"@id": "schema:PreventionIndication"},
"PriceSpecification": {"@id": "schema:PriceSpecification"},
"PrimaryCare": {"@id": "schema:PrimaryCare"},
"Prion": {"@id": "schema:Prion"},
"Product": {"@id": "schema:Product"},
"ProductModel": {"@id": "schema:ProductModel"},
"ProductReturnEnumeration": {"@id": "schema:ProductReturnEnumeration"},
"ProductReturnFiniteReturnWindow": {
"@id": "schema:ProductReturnFiniteReturnWindow"
},
"ProductReturnNotPermitted": {"@id": "schema:ProductReturnNotPermitted"},
"ProductReturnPolicy": {"@id": "schema:ProductReturnPolicy"},
"ProductReturnUnlimitedWindow": {"@id": "schema:ProductReturnUnlimitedWindow"},
"ProductReturnUnspecified": {"@id": "schema:ProductReturnUnspecified"},
"ProfessionalService": {"@id": "schema:ProfessionalService"},
"ProfilePage": {"@id": "schema:ProfilePage"},
"PrognosisHealthAspect": {"@id": "schema:PrognosisHealthAspect"},
"ProgramMembership": {"@id": "schema:ProgramMembership"},
"Project": {"@id": "schema:Project"},
"PronounceableText": {"@id": "schema:PronounceableText"},
"Property": {"@id": "schema:Property"},
"PropertyValue": {"@id": "schema:PropertyValue"},
"PropertyValueSpecification": {"@id": "schema:PropertyValueSpecification"},
"Protozoa": {"@id": "schema:Protozoa"},
"Psychiatric": {"@id": "schema:Psychiatric"},
"PsychologicalTreatment": {"@id": "schema:PsychologicalTreatment"},
"PublicHealth": {"@id": "schema:PublicHealth"},
"PublicHolidays": {"@id": "schema:PublicHolidays"},
"PublicSwimmingPool": {"@id": "schema:PublicSwimmingPool"},
"PublicToilet": {"@id": "schema:PublicToilet"},
"PublicationEvent": {"@id": "schema:PublicationEvent"},
"PublicationIssue": {"@id": "schema:PublicationIssue"},
"PublicationVolume": {"@id": "schema:PublicationVolume"},
"Pulmonary": {"@id": "schema:Pulmonary"},
"QAPage": {"@id": "schema:QAPage"},
"QualitativeValue": {"@id": "schema:QualitativeValue"},
"QuantitativeValue": {"@id": "schema:QuantitativeValue"},
"QuantitativeValueDistribution": {"@id": "schema:QuantitativeValueDistribution"},
"Quantity": {"@id": "schema:Quantity"},
"Question": {"@id": "schema:Question"},
"Quotation": {"@id": "schema:Quotation"},
"QuoteAction": {"@id": "schema:QuoteAction"},
"RVPark": {"@id": "schema:RVPark"},
"RadiationTherapy": {"@id": "schema:RadiationTherapy"},
"RadioBroadcastService": {"@id": "schema:RadioBroadcastService"},
"RadioChannel": {"@id": "schema:RadioChannel"},
"RadioClip": {"@id": "schema:RadioClip"},
"RadioEpisode": {"@id": "schema:RadioEpisode"},
"RadioSeason": {"@id": "schema:RadioSeason"},
"RadioSeries": {"@id": "schema:RadioSeries"},
"RadioStation": {"@id": "schema:RadioStation"},
"Radiography": {"@id": "schema:Radiography"},
"RandomizedTrial": {"@id": "schema:RandomizedTrial"},
"Rating": {"@id": "schema:Rating"},
"ReactAction": {"@id": "schema:ReactAction"},
"ReadAction": {"@id": "schema:ReadAction"},
"ReadPermission": {"@id": "schema:ReadPermission"},
"RealEstateAgent": {"@id": "schema:RealEstateAgent"},
"RealEstateListing": {"@id": "schema:RealEstateListing"},
"RearWheelDriveConfiguration": {"@id": "schema:RearWheelDriveConfiguration"},
"ReceiveAction": {"@id": "schema:ReceiveAction"},
"Recipe": {"@id": "schema:Recipe"},
"Recommendation": {"@id": "schema:Recommendation"},
"RecommendedDoseSchedule": {"@id": "schema:RecommendedDoseSchedule"},
"Recruiting": {"@id": "schema:Recruiting"},
"RecyclingCenter": {"@id": "schema:RecyclingCenter"},
"RefundTypeEnumeration": {"@id": "schema:RefundTypeEnumeration"},
"RefurbishedCondition": {"@id": "schema:RefurbishedCondition"},
"RegisterAction": {"@id": "schema:RegisterAction"},
"Registry": {"@id": "schema:Registry"},
"ReimbursementCap": {"@id": "schema:ReimbursementCap"},
"RejectAction": {"@id": "schema:RejectAction"},
"RelatedTopicsHealthAspect": {"@id": "schema:RelatedTopicsHealthAspect"},
"RemixAlbum": {"@id": "schema:RemixAlbum"},
"Renal": {"@id": "schema:Renal"},
"RentAction": {"@id": "schema:RentAction"},
"RentalCarReservation": {"@id": "schema:RentalCarReservation"},
"RentalVehicleUsage": {"@id": "schema:RentalVehicleUsage"},
"RepaymentSpecification": {"@id": "schema:RepaymentSpecification"},
"ReplaceAction": {"@id": "schema:ReplaceAction"},
"ReplyAction": {"@id": "schema:ReplyAction"},
"Report": {"@id": "schema:Report"},
"ReportageNewsArticle": {"@id": "schema:ReportageNewsArticle"},
"ReportedDoseSchedule": {"@id": "schema:ReportedDoseSchedule"},
"ResearchProject": {"@id": "schema:ResearchProject"},
"Researcher": {"@id": "schema:Researcher"},
"Reservation": {"@id": "schema:Reservation"},
"ReservationCancelled": {"@id": "schema:ReservationCancelled"},
"ReservationConfirmed": {"@id": "schema:ReservationConfirmed"},
"ReservationHold": {"@id": "schema:ReservationHold"},
"ReservationPackage": {"@id": "schema:ReservationPackage"},
"ReservationPending": {"@id": "schema:ReservationPending"},
"ReservationStatusType": {"@id": "schema:ReservationStatusType"},
"ReserveAction": {"@id": "schema:ReserveAction"},
"Reservoir": {"@id": "schema:Reservoir"},
"Residence": {"@id": "schema:Residence"},
"Resort": {"@id": "schema:Resort"},
"RespiratoryTherapy": {"@id": "schema:RespiratoryTherapy"},
"Restaurant": {"@id": "schema:Restaurant"},
"RestockingFees": {"@id": "schema:RestockingFees"},
"RestrictedDiet": {"@id": "schema:RestrictedDiet"},
"ResultsAvailable": {"@id": "schema:ResultsAvailable"},
"ResultsNotAvailable": {"@id": "schema:ResultsNotAvailable"},
"ResumeAction": {"@id": "schema:ResumeAction"},
"Retail": {"@id": "schema:Retail"},
"ReturnAction": {"@id": "schema:ReturnAction"},
"ReturnFeesEnumeration": {"@id": "schema:ReturnFeesEnumeration"},
"ReturnShippingFees": {"@id": "schema:ReturnShippingFees"},
"Review": {"@id": "schema:Review"},
"ReviewAction": {"@id": "schema:ReviewAction"},
"ReviewNewsArticle": {"@id": "schema:ReviewNewsArticle"},
"Rheumatologic": {"@id": "schema:Rheumatologic"},
"RightHandDriving": {"@id": "schema:RightHandDriving"},
"RisksOrComplicationsHealthAspect": {
"@id": "schema:RisksOrComplicationsHealthAspect"
},
"RiverBodyOfWater": {"@id": "schema:RiverBodyOfWater"},
"Role": {"@id": "schema:Role"},
"RoofingContractor": {"@id": "schema:RoofingContractor"},
"Room": {"@id": "schema:Room"},
"RsvpAction": {"@id": "schema:RsvpAction"},
"RsvpResponseMaybe": {"@id": "schema:RsvpResponseMaybe"},
"RsvpResponseNo": {"@id": "schema:RsvpResponseNo"},
"RsvpResponseType": {"@id": "schema:RsvpResponseType"},
"RsvpResponseYes": {"@id": "schema:RsvpResponseYes"},
"SaleEvent": {"@id": "schema:SaleEvent"},
"SatiricalArticle": {"@id": "schema:SatiricalArticle"},
"Saturday": {"@id": "schema:Saturday"},
"Schedule": {"@id": "schema:Schedule"},
"ScheduleAction": {"@id": "schema:ScheduleAction"},
"ScholarlyArticle": {"@id": "schema:ScholarlyArticle"},
"School": {"@id": "schema:School"},
"SchoolDistrict": {"@id": "schema:SchoolDistrict"},
"ScreeningEvent": {"@id": "schema:ScreeningEvent"},
"ScreeningHealthAspect": {"@id": "schema:ScreeningHealthAspect"},
"Sculpture": {"@id": "schema:Sculpture"},
"SeaBodyOfWater": {"@id": "schema:SeaBodyOfWater"},
"SearchAction": {"@id": "schema:SearchAction"},
"SearchResultsPage": {"@id": "schema:SearchResultsPage"},
"Season": {"@id": "schema:Season"},
"Seat": {"@id": "schema:Seat"},
"SeatingMap": {"@id": "schema:SeatingMap"},
"SeeDoctorHealthAspect": {"@id": "schema:SeeDoctorHealthAspect"},
"SelfCareHealthAspect": {"@id": "schema:SelfCareHealthAspect"},
"SelfStorage": {"@id": "schema:SelfStorage"},
"SellAction": {"@id": "schema:SellAction"},
"SendAction": {"@id": "schema:SendAction"},
"Series": {"@id": "schema:Series"},
"Service": {"@id": "schema:Service"},
"ServiceChannel": {"@id": "schema:ServiceChannel"},
"ShareAction": {"@id": "schema:ShareAction"},
"SheetMusic": {"@id": "schema:SheetMusic"},
"ShoeStore": {"@id": "schema:ShoeStore"},
"ShoppingCenter": {"@id": "schema:ShoppingCenter"},
"ShortStory": {"@id": "schema:ShortStory"},
"SideEffectsHealthAspect": {"@id": "schema:SideEffectsHealthAspect"},
"SingleBlindedTrial": {"@id": "schema:SingleBlindedTrial"},
"SingleCenterTrial": {"@id": "schema:SingleCenterTrial"},
"SingleFamilyResidence": {"@id": "schema:SingleFamilyResidence"},
"SinglePlayer": {"@id": "schema:SinglePlayer"},
"SingleRelease": {"@id": "schema:SingleRelease"},
"SiteNavigationElement": {"@id": "schema:SiteNavigationElement"},
"SkiResort": {"@id": "schema:SkiResort"},
"Skin": {"@id": "schema:Skin"},
"SocialEvent": {"@id": "schema:SocialEvent"},
"SocialMediaPosting": {"@id": "schema:SocialMediaPosting"},
"SoftwareApplication": {"@id": "schema:SoftwareApplication"},
"SoftwareSourceCode": {"@id": "schema:SoftwareSourceCode"},
"SoldOut": {"@id": "schema:SoldOut"},
"SomeProducts": {"@id": "schema:SomeProducts"},
"SoundtrackAlbum": {"@id": "schema:SoundtrackAlbum"},
"SpeakableSpecification": {"@id": "schema:SpeakableSpecification"},
"SpecialAnnouncement": {"@id": "schema:SpecialAnnouncement"},
"Specialty": {"@id": "schema:Specialty"},
"SpeechPathology": {"@id": "schema:SpeechPathology"},
"SpokenWordAlbum": {"@id": "schema:SpokenWordAlbum"},
"SportingGoodsStore": {"@id": "schema:SportingGoodsStore"},
"SportsActivityLocation": {"@id": "schema:SportsActivityLocation"},
"SportsClub": {"@id": "schema:SportsClub"},
"SportsEvent": {"@id": "schema:SportsEvent"},
"SportsOrganization": {"@id": "schema:SportsOrganization"},
"SportsTeam": {"@id": "schema:SportsTeam"},
"SpreadsheetDigitalDocument": {"@id": "schema:SpreadsheetDigitalDocument"},
"StadiumOrArena": {"@id": "schema:StadiumOrArena"},
"StagesHealthAspect": {"@id": "schema:StagesHealthAspect"},
"State": {"@id": "schema:State"},
"StatisticalPopulation": {"@id": "schema:StatisticalPopulation"},
"SteeringPositionValue": {"@id": "schema:SteeringPositionValue"},
"Store": {"@id": "schema:Store"},
"StoreCreditRefund": {"@id": "schema:StoreCreditRefund"},
"StrengthTraining": {"@id": "schema:StrengthTraining"},
"StructuredValue": {"@id": "schema:StructuredValue"},
"StudioAlbum": {"@id": "schema:StudioAlbum"},
"StupidType": {"@id": "schema:StupidType"},
"SubscribeAction": {"@id": "schema:SubscribeAction"},
"Substance": {"@id": "schema:Substance"},
"SubwayStation": {"@id": "schema:SubwayStation"},
"Suite": {"@id": "schema:Suite"},
"Sunday": {"@id": "schema:Sunday"},
"SuperficialAnatomy": {"@id": "schema:SuperficialAnatomy"},
"Surgical": {"@id": "schema:Surgical"},
"SurgicalProcedure": {"@id": "schema:SurgicalProcedure"},
"SuspendAction": {"@id": "schema:SuspendAction"},
"Suspended": {"@id": "schema:Suspended"},
"SymptomsHealthAspect": {"@id": "schema:SymptomsHealthAspect"},
"Synagogue": {"@id": "schema:Synagogue"},
"TVClip": {"@id": "schema:TVClip"},
"TVEpisode": {"@id": "schema:TVEpisode"},
"TVSeason": {"@id": "schema:TVSeason"},
"TVSeries": {"@id": "schema:TVSeries"},
"Table": {"@id": "schema:Table"},
"TakeAction": {"@id": "schema:TakeAction"},
"TattooParlor": {"@id": "schema:TattooParlor"},
"Taxi": {"@id": "schema:Taxi"},
"TaxiReservation": {"@id": "schema:TaxiReservation"},
"TaxiService": {"@id": "schema:TaxiService"},
"TaxiStand": {"@id": "schema:TaxiStand"},
"TaxiVehicleUsage": {"@id": "schema:TaxiVehicleUsage"},
"TechArticle": {"@id": "schema:TechArticle"},
"TelevisionChannel": {"@id": "schema:TelevisionChannel"},
"TelevisionStation": {"@id": "schema:TelevisionStation"},
"TennisComplex": {"@id": "schema:TennisComplex"},
"Terminated": {"@id": "schema:Terminated"},
"Text": {"@id": "schema:Text"},
"TextDigitalDocument": {"@id": "schema:TextDigitalDocument"},
"TheaterEvent": {"@id": "schema:TheaterEvent"},
"TheaterGroup": {"@id": "schema:TheaterGroup"},
"Therapeutic": {"@id": "schema:Therapeutic"},
"TherapeuticProcedure": {"@id": "schema:TherapeuticProcedure"},
"Thesis": {"@id": "schema:Thesis"},
"Thing": {"@id": "schema:Thing"},
"Throat": {"@id": "schema:Throat"},
"Thursday": {"@id": "schema:Thursday"},
"Ticket": {"@id": "schema:Ticket"},
"TieAction": {"@id": "schema:TieAction"},
"Time": {"@id": "schema:Time"},
"TipAction": {"@id": "schema:TipAction"},
"TireShop": {"@id": "schema:TireShop"},
"TollFree": {"@id": "schema:TollFree"},
"TouristAttraction": {"@id": "schema:TouristAttraction"},
"TouristDestination": {"@id": "schema:TouristDestination"},
"TouristInformationCenter": {"@id": "schema:TouristInformationCenter"},
"TouristTrip": {"@id": "schema:TouristTrip"},
"Toxicologic": {"@id": "schema:Toxicologic"},
"ToyStore": {"@id": "schema:ToyStore"},
"TrackAction": {"@id": "schema:TrackAction"},
"TradeAction": {"@id": "schema:TradeAction"},
"TraditionalChinese": {"@id": "schema:TraditionalChinese"},
"TrainReservation": {"@id": "schema:TrainReservation"},
"TrainStation": {"@id": "schema:TrainStation"},
"TrainTrip": {"@id": "schema:TrainTrip"},
"TransferAction": {"@id": "schema:TransferAction"},
"TransitMap": {"@id": "schema:TransitMap"},
"TravelAction": {"@id": "schema:TravelAction"},
"TravelAgency": {"@id": "schema:TravelAgency"},
"TreatmentIndication": {"@id": "schema:TreatmentIndication"},
"TreatmentsHealthAspect": {"@id": "schema:TreatmentsHealthAspect"},
"Trip": {"@id": "schema:Trip"},
"TripleBlindedTrial": {"@id": "schema:TripleBlindedTrial"},
"True": {"@id": "schema:True"},
"Tuesday": {"@id": "schema:Tuesday"},
"TypeAndQuantityNode": {"@id": "schema:TypeAndQuantityNode"},
"TypesHealthAspect": {"@id": "schema:TypesHealthAspect"},
"URL": {"@id": "schema:URL"},
"Ultrasound": {"@id": "schema:Ultrasound"},
"UnRegisterAction": {"@id": "schema:UnRegisterAction"},
"UnitPriceSpecification": {"@id": "schema:UnitPriceSpecification"},
"UnofficialLegalValue": {"@id": "schema:UnofficialLegalValue"},
"UpdateAction": {"@id": "schema:UpdateAction"},
"Urologic": {"@id": "schema:Urologic"},
"UsageOrScheduleHealthAspect": {"@id": "schema:UsageOrScheduleHealthAspect"},
"UseAction": {"@id": "schema:UseAction"},
"UsedCondition": {"@id": "schema:UsedCondition"},
"UserBlocks": {"@id": "schema:UserBlocks"},
"UserCheckins": {"@id": "schema:UserCheckins"},
"UserComments": {"@id": "schema:UserComments"},
"UserDownloads": {"@id": "schema:UserDownloads"},
"UserInteraction": {"@id": "schema:UserInteraction"},
"UserLikes": {"@id": "schema:UserLikes"},
"UserPageVisits": {"@id": "schema:UserPageVisits"},
"UserPlays": {"@id": "schema:UserPlays"},
"UserPlusOnes": {"@id": "schema:UserPlusOnes"},
"UserReview": {"@id": "schema:UserReview"},
"UserTweets": {"@id": "schema:UserTweets"},
"VeganDiet": {"@id": "schema:VeganDiet"},
"VegetarianDiet": {"@id": "schema:VegetarianDiet"},
"Vehicle": {"@id": "schema:Vehicle"},
"Vein": {"@id": "schema:Vein"},
"VenueMap": {"@id": "schema:VenueMap"},
"Vessel": {"@id": "schema:Vessel"},
"VeterinaryCare": {"@id": "schema:VeterinaryCare"},
"VideoGallery": {"@id": "schema:VideoGallery"},
"VideoGame": {"@id": "schema:VideoGame"},
"VideoGameClip": {"@id": "schema:VideoGameClip"},
"VideoGameSeries": {"@id": "schema:VideoGameSeries"},
"VideoObject": {"@id": "schema:VideoObject"},
"ViewAction": {"@id": "schema:ViewAction"},
"VinylFormat": {"@id": "schema:VinylFormat"},
"VirtualLocation": {"@id": "schema:VirtualLocation"},
"Virus": {"@id": "schema:Virus"},
"VisualArtsEvent": {"@id": "schema:VisualArtsEvent"},
"VisualArtwork": {"@id": "schema:VisualArtwork"},
"VitalSign": {"@id": "schema:VitalSign"},
"Volcano": {"@id": "schema:Volcano"},
"VoteAction": {"@id": "schema:VoteAction"},
"WPAdBlock": {"@id": "schema:WPAdBlock"},
"WPFooter": {"@id": "schema:WPFooter"},
"WPHeader": {"@id": "schema:WPHeader"},
"WPSideBar": {"@id": "schema:WPSideBar"},
"WantAction": {"@id": "schema:WantAction"},
"WarrantyPromise": {"@id": "schema:WarrantyPromise"},
"WarrantyScope": {"@id": "schema:WarrantyScope"},
"WatchAction": {"@id": "schema:WatchAction"},
"Waterfall": {"@id": "schema:Waterfall"},
"WearAction": {"@id": "schema:WearAction"},
"WebAPI": {"@id": "schema:WebAPI"},
"WebApplication": {"@id": "schema:WebApplication"},
"WebContent": {"@id": "schema:WebContent"},
"WebPage": {"@id": "schema:WebPage"},
"WebPageElement": {"@id": "schema:WebPageElement"},
"WebSite": {"@id": "schema:WebSite"},
"Wednesday": {"@id": "schema:Wednesday"},
"WesternConventional": {"@id": "schema:WesternConventional"},
"Wholesale": {"@id": "schema:Wholesale"},
"WholesaleStore": {"@id": "schema:WholesaleStore"},
"WinAction": {"@id": "schema:WinAction"},
"Winery": {"@id": "schema:Winery"},
"Withdrawn": {"@id": "schema:Withdrawn"},
"WorkBasedProgram": {"@id": "schema:WorkBasedProgram"},
"WorkersUnion": {"@id": "schema:WorkersUnion"},
"WriteAction": {"@id": "schema:WriteAction"},
"WritePermission": {"@id": "schema:WritePermission"},
"XPathType": {"@id": "schema:XPathType"},
"XRay": {"@id": "schema:XRay"},
"ZoneBoardingPolicy": {"@id": "schema:ZoneBoardingPolicy"},
"Zoo": {"@id": "schema:Zoo"},
"about": {"@id": "schema:about"},
"abridged": {"@id": "schema:abridged"},
"abstract": {"@id": "schema:abstract"},
"accelerationTime": {"@id": "schema:accelerationTime"},
"acceptedAnswer": {"@id": "schema:acceptedAnswer"},
"acceptedOffer": {"@id": "schema:acceptedOffer"},
"acceptedPaymentMethod": {"@id": "schema:acceptedPaymentMethod"},
"acceptsReservations": {"@id": "schema:acceptsReservations"},
"accessCode": {"@id": "schema:accessCode"},
"accessMode": {"@id": "schema:accessMode"},
"accessModeSufficient": {"@id": "schema:accessModeSufficient"},
"accessibilityAPI": {"@id": "schema:accessibilityAPI"},
"accessibilityControl": {"@id": "schema:accessibilityControl"},
"accessibilityFeature": {"@id": "schema:accessibilityFeature"},
"accessibilityHazard": {"@id": "schema:accessibilityHazard"},
"accessibilitySummary": {"@id": "schema:accessibilitySummary"},
"accommodationCategory": {"@id": "schema:accommodationCategory"},
"accommodationFloorPlan": {"@id": "schema:accommodationFloorPlan"},
"accountId": {"@id": "schema:accountId"},
"accountMinimumInflow": {"@id": "schema:accountMinimumInflow"},
"accountOverdraftLimit": {"@id": "schema:accountOverdraftLimit"},
"accountablePerson": {"@id": "schema:accountablePerson"},
"acquireLicensePage": {"@id": "schema:acquireLicensePage", "@type": "@id"},
"acquiredFrom": {"@id": "schema:acquiredFrom"},
"acrissCode": {"@id": "schema:acrissCode"},
"actionAccessibilityRequirement": {"@id": "schema:actionAccessibilityRequirement"},
"actionApplication": {"@id": "schema:actionApplication"},
"actionOption": {"@id": "schema:actionOption"},
"actionPlatform": {"@id": "schema:actionPlatform"},
"actionStatus": {"@id": "schema:actionStatus"},
"actionableFeedbackPolicy": {
"@id": "schema:actionableFeedbackPolicy",
"@type": "@id",
},
"activeIngredient": {"@id": "schema:activeIngredient"},
"activityDuration": {"@id": "schema:activityDuration"},
"activityFrequency": {"@id": "schema:activityFrequency"},
"actor": {"@id": "schema:actor"},
"actors": {"@id": "schema:actors"},
"addOn": {"@id": "schema:addOn"},
"additionalName": {"@id": "schema:additionalName"},
"additionalNumberOfGuests": {"@id": "schema:additionalNumberOfGuests"},
"additionalProperty": {"@id": "schema:additionalProperty"},
"additionalType": {"@id": "schema:additionalType", "@type": "@id"},
"additionalVariable": {"@id": "schema:additionalVariable"},
"address": {"@id": "schema:address"},
"addressCountry": {"@id": "schema:addressCountry"},
"addressLocality": {"@id": "schema:addressLocality"},
"addressRegion": {"@id": "schema:addressRegion"},
"administrationRoute": {"@id": "schema:administrationRoute"},
"advanceBookingRequirement": {"@id": "schema:advanceBookingRequirement"},
"adverseOutcome": {"@id": "schema:adverseOutcome"},
"affectedBy": {"@id": "schema:affectedBy"},
"affiliation": {"@id": "schema:affiliation"},
"afterMedia": {"@id": "schema:afterMedia", "@type": "@id"},
"agent": {"@id": "schema:agent"},
"aggregateRating": {"@id": "schema:aggregateRating"},
"aircraft": {"@id": "schema:aircraft"},
"album": {"@id": "schema:album"},
"albumProductionType": {"@id": "schema:albumProductionType"},
"albumRelease": {"@id": "schema:albumRelease"},
"albumReleaseType": {"@id": "schema:albumReleaseType"},
"albums": {"@id": "schema:albums"},
"alcoholWarning": {"@id": "schema:alcoholWarning"},
"algorithm": {"@id": "schema:algorithm"},
"alignmentType": {"@id": "schema:alignmentType"},
"alternateName": {"@id": "schema:alternateName"},
"alternativeHeadline": {"@id": "schema:alternativeHeadline"},
"alumni": {"@id": "schema:alumni"},
"alumniOf": {"@id": "schema:alumniOf"},
"amenityFeature": {"@id": "schema:amenityFeature"},
"amount": {"@id": "schema:amount"},
"amountOfThisGood": {"@id": "schema:amountOfThisGood"},
"annualPercentageRate": {"@id": "schema:annualPercentageRate"},
"answerCount": {"@id": "schema:answerCount"},
"antagonist": {"@id": "schema:antagonist"},
"appearance": {"@id": "schema:appearance"},
"applicableLocation": {"@id": "schema:applicableLocation"},
"applicantLocationRequirements": {"@id": "schema:applicantLocationRequirements"},
"application": {"@id": "schema:application"},
"applicationCategory": {"@id": "schema:applicationCategory"},
"applicationContact": {"@id": "schema:applicationContact"},
"applicationDeadline": {"@id": "schema:applicationDeadline", "@type": "Date"},
"applicationStartDate": {"@id": "schema:applicationStartDate", "@type": "Date"},
"applicationSubCategory": {"@id": "schema:applicationSubCategory"},
"applicationSuite": {"@id": "schema:applicationSuite"},
"appliesToDeliveryMethod": {"@id": "schema:appliesToDeliveryMethod"},
"appliesToPaymentMethod": {"@id": "schema:appliesToPaymentMethod"},
"archiveHeld": {"@id": "schema:archiveHeld"},
"area": {"@id": "schema:area"},
"areaServed": {"@id": "schema:areaServed"},
"arrivalAirport": {"@id": "schema:arrivalAirport"},
"arrivalBusStop": {"@id": "schema:arrivalBusStop"},
"arrivalGate": {"@id": "schema:arrivalGate"},
"arrivalPlatform": {"@id": "schema:arrivalPlatform"},
"arrivalStation": {"@id": "schema:arrivalStation"},
"arrivalTerminal": {"@id": "schema:arrivalTerminal"},
"arrivalTime": {"@id": "schema:arrivalTime", "@type": "DateTime"},
"artEdition": {"@id": "schema:artEdition"},
"artMedium": {"@id": "schema:artMedium"},
"arterialBranch": {"@id": "schema:arterialBranch"},
"artform": {"@id": "schema:artform"},
"articleBody": {"@id": "schema:articleBody"},
"articleSection": {"@id": "schema:articleSection"},
"artist": {"@id": "schema:artist"},
"artworkSurface": {"@id": "schema:artworkSurface"},
"aspect": {"@id": "schema:aspect"},
"assembly": {"@id": "schema:assembly"},
"assemblyVersion": {"@id": "schema:assemblyVersion"},
"associatedAnatomy": {"@id": "schema:associatedAnatomy"},
"associatedArticle": {"@id": "schema:associatedArticle"},
"associatedMedia": {"@id": "schema:associatedMedia"},
"associatedPathophysiology": {"@id": "schema:associatedPathophysiology"},
"athlete": {"@id": "schema:athlete"},
"attendee": {"@id": "schema:attendee"},
"attendees": {"@id": "schema:attendees"},
"audience": {"@id": "schema:audience"},
"audienceType": {"@id": "schema:audienceType"},
"audio": {"@id": "schema:audio"},
"authenticator": {"@id": "schema:authenticator"},
"author": {"@id": "schema:author"},
"availability": {"@id": "schema:availability"},
"availabilityEnds": {"@id": "schema:availabilityEnds", "@type": "Date"},
"availabilityStarts": {"@id": "schema:availabilityStarts", "@type": "Date"},
"availableAtOrFrom": {"@id": "schema:availableAtOrFrom"},
"availableChannel": {"@id": "schema:availableChannel"},
"availableDeliveryMethod": {"@id": "schema:availableDeliveryMethod"},
"availableFrom": {"@id": "schema:availableFrom", "@type": "DateTime"},
"availableIn": {"@id": "schema:availableIn"},
"availableLanguage": {"@id": "schema:availableLanguage"},
"availableOnDevice": {"@id": "schema:availableOnDevice"},
"availableService": {"@id": "schema:availableService"},
"availableStrength": {"@id": "schema:availableStrength"},
"availableTest": {"@id": "schema:availableTest"},
"availableThrough": {"@id": "schema:availableThrough", "@type": "DateTime"},
"award": {"@id": "schema:award"},
"awards": {"@id": "schema:awards"},
"awayTeam": {"@id": "schema:awayTeam"},
"backstory": {"@id": "schema:backstory"},
"bankAccountType": {"@id": "schema:bankAccountType"},
"baseSalary": {"@id": "schema:baseSalary"},
"bccRecipient": {"@id": "schema:bccRecipient"},
"bed": {"@id": "schema:bed"},
"beforeMedia": {"@id": "schema:beforeMedia", "@type": "@id"},
"beneficiaryBank": {"@id": "schema:beneficiaryBank"},
"benefits": {"@id": "schema:benefits"},
"benefitsSummaryUrl": {"@id": "schema:benefitsSummaryUrl", "@type": "@id"},
"bestRating": {"@id": "schema:bestRating"},
"billingAddress": {"@id": "schema:billingAddress"},
"billingIncrement": {"@id": "schema:billingIncrement"},
"billingPeriod": {"@id": "schema:billingPeriod"},
"biomechnicalClass": {"@id": "schema:biomechnicalClass"},
"birthDate": {"@id": "schema:birthDate", "@type": "Date"},
"birthPlace": {"@id": "schema:birthPlace"},
"bitrate": {"@id": "schema:bitrate"},
"blogPost": {"@id": "schema:blogPost"},
"blogPosts": {"@id": "schema:blogPosts"},
"bloodSupply": {"@id": "schema:bloodSupply"},
"boardingGroup": {"@id": "schema:boardingGroup"},
"boardingPolicy": {"@id": "schema:boardingPolicy"},
"bodyLocation": {"@id": "schema:bodyLocation"},
"bodyType": {"@id": "schema:bodyType"},
"bookEdition": {"@id": "schema:bookEdition"},
"bookFormat": {"@id": "schema:bookFormat"},
"bookingAgent": {"@id": "schema:bookingAgent"},
"bookingTime": {"@id": "schema:bookingTime", "@type": "DateTime"},
"borrower": {"@id": "schema:borrower"},
"box": {"@id": "schema:box"},
"branch": {"@id": "schema:branch"},
"branchCode": {"@id": "schema:branchCode"},
"branchOf": {"@id": "schema:branchOf"},
"brand": {"@id": "schema:brand"},
"breadcrumb": {"@id": "schema:breadcrumb"},
"breastfeedingWarning": {"@id": "schema:breastfeedingWarning"},
"broadcastAffiliateOf": {"@id": "schema:broadcastAffiliateOf"},
"broadcastChannelId": {"@id": "schema:broadcastChannelId"},
"broadcastDisplayName": {"@id": "schema:broadcastDisplayName"},
"broadcastFrequency": {"@id": "schema:broadcastFrequency"},
"broadcastFrequencyValue": {"@id": "schema:broadcastFrequencyValue"},
"broadcastOfEvent": {"@id": "schema:broadcastOfEvent"},
"broadcastServiceTier": {"@id": "schema:broadcastServiceTier"},
"broadcastSignalModulation": {"@id": "schema:broadcastSignalModulation"},
"broadcastSubChannel": {"@id": "schema:broadcastSubChannel"},
"broadcastTimezone": {"@id": "schema:broadcastTimezone"},
"broadcaster": {"@id": "schema:broadcaster"},
"broker": {"@id": "schema:broker"},
"browserRequirements": {"@id": "schema:browserRequirements"},
"busName": {"@id": "schema:busName"},
"busNumber": {"@id": "schema:busNumber"},
"businessFunction": {"@id": "schema:businessFunction"},
"buyer": {"@id": "schema:buyer"},
"byArtist": {"@id": "schema:byArtist"},
"byDay": {"@id": "schema:byDay"},
"byMonth": {"@id": "schema:byMonth"},
"byMonthDay": {"@id": "schema:byMonthDay"},
"callSign": {"@id": "schema:callSign"},
"calories": {"@id": "schema:calories"},
"candidate": {"@id": "schema:candidate"},
"caption": {"@id": "schema:caption"},
"carbohydrateContent": {"@id": "schema:carbohydrateContent"},
"cargoVolume": {"@id": "schema:cargoVolume"},
"carrier": {"@id": "schema:carrier"},
"carrierRequirements": {"@id": "schema:carrierRequirements"},
"cashBack": {"@id": "schema:cashBack"},
"catalog": {"@id": "schema:catalog"},
"catalogNumber": {"@id": "schema:catalogNumber"},
"category": {"@id": "schema:category"},
"causeOf": {"@id": "schema:causeOf"},
"ccRecipient": {"@id": "schema:ccRecipient"},
"character": {"@id": "schema:character"},
"characterAttribute": {"@id": "schema:characterAttribute"},
"characterName": {"@id": "schema:characterName"},
"cheatCode": {"@id": "schema:cheatCode"},
"checkinTime": {"@id": "schema:checkinTime", "@type": "DateTime"},
"checkoutTime": {"@id": "schema:checkoutTime", "@type": "DateTime"},
"childMaxAge": {"@id": "schema:childMaxAge"},
"childMinAge": {"@id": "schema:childMinAge"},
"children": {"@id": "schema:children"},
"cholesterolContent": {"@id": "schema:cholesterolContent"},
"circle": {"@id": "schema:circle"},
"citation": {"@id": "schema:citation"},
"claimReviewed": {"@id": "schema:claimReviewed"},
"clincalPharmacology": {"@id": "schema:clincalPharmacology"},
"clinicalPharmacology": {"@id": "schema:clinicalPharmacology"},
"clipNumber": {"@id": "schema:clipNumber"},
"closes": {"@id": "schema:closes"},
"coach": {"@id": "schema:coach"},
"code": {"@id": "schema:code"},
"codeRepository": {"@id": "schema:codeRepository", "@type": "@id"},
"codeSampleType": {"@id": "schema:codeSampleType"},
"codeValue": {"@id": "schema:codeValue"},
"codingSystem": {"@id": "schema:codingSystem"},
"colleague": {"@id": "schema:colleague", "@type": "@id"},
"colleagues": {"@id": "schema:colleagues"},
"collection": {"@id": "schema:collection"},
"collectionSize": {"@id": "schema:collectionSize"},
"color": {"@id": "schema:color"},
"colorist": {"@id": "schema:colorist"},
"comment": {"@id": "schema:comment"},
"commentCount": {"@id": "schema:commentCount"},
"commentText": {"@id": "schema:commentText"},
"commentTime": {"@id": "schema:commentTime", "@type": "Date"},
"competencyRequired": {"@id": "schema:competencyRequired"},
"competitor": {"@id": "schema:competitor"},
"composer": {"@id": "schema:composer"},
"comprisedOf": {"@id": "schema:comprisedOf"},
"conditionsOfAccess": {"@id": "schema:conditionsOfAccess"},
"confirmationNumber": {"@id": "schema:confirmationNumber"},
"connectedTo": {"@id": "schema:connectedTo"},
"constrainingProperty": {"@id": "schema:constrainingProperty"},
"contactOption": {"@id": "schema:contactOption"},
"contactPoint": {"@id": "schema:contactPoint"},
"contactPoints": {"@id": "schema:contactPoints"},
"contactType": {"@id": "schema:contactType"},
"contactlessPayment": {"@id": "schema:contactlessPayment"},
"containedIn": {"@id": "schema:containedIn"},
"containedInPlace": {"@id": "schema:containedInPlace"},
"containsPlace": {"@id": "schema:containsPlace"},
"containsSeason": {"@id": "schema:containsSeason"},
"contentLocation": {"@id": "schema:contentLocation"},
"contentRating": {"@id": "schema:contentRating"},
"contentReferenceTime": {
"@id": "schema:contentReferenceTime",
"@type": "DateTime",
},
"contentSize": {"@id": "schema:contentSize"},
"contentType": {"@id": "schema:contentType"},
"contentUrl": {"@id": "schema:contentUrl", "@type": "@id"},
"contraindication": {"@id": "schema:contraindication"},
"contributor": {"@id": "schema:contributor"},
"cookTime": {"@id": "schema:cookTime"},
"cookingMethod": {"@id": "schema:cookingMethod"},
"copyrightHolder": {"@id": "schema:copyrightHolder"},
"copyrightYear": {"@id": "schema:copyrightYear"},
"correction": {"@id": "schema:correction"},
"correctionsPolicy": {"@id": "schema:correctionsPolicy", "@type": "@id"},
"costCategory": {"@id": "schema:costCategory"},
"costCurrency": {"@id": "schema:costCurrency"},
"costOrigin": {"@id": "schema:costOrigin"},
"costPerUnit": {"@id": "schema:costPerUnit"},
"countriesNotSupported": {"@id": "schema:countriesNotSupported"},
"countriesSupported": {"@id": "schema:countriesSupported"},
"countryOfOrigin": {"@id": "schema:countryOfOrigin"},
"course": {"@id": "schema:course"},
"courseCode": {"@id": "schema:courseCode"},
"courseMode": {"@id": "schema:courseMode"},
"coursePrerequisites": {"@id": "schema:coursePrerequisites"},
"courseWorkload": {"@id": "schema:courseWorkload"},
"coverageEndTime": {"@id": "schema:coverageEndTime", "@type": "DateTime"},
"coverageStartTime": {"@id": "schema:coverageStartTime", "@type": "DateTime"},
"creativeWorkStatus": {"@id": "schema:creativeWorkStatus"},
"creator": {"@id": "schema:creator"},
"credentialCategory": {"@id": "schema:credentialCategory"},
"creditedTo": {"@id": "schema:creditedTo"},
"cssSelector": {"@id": "schema:cssSelector"},
"currenciesAccepted": {"@id": "schema:currenciesAccepted"},
"currency": {"@id": "schema:currency"},
"currentExchangeRate": {"@id": "schema:currentExchangeRate"},
"customer": {"@id": "schema:customer"},
"dataFeedElement": {"@id": "schema:dataFeedElement"},
"dataset": {"@id": "schema:dataset"},
"datasetTimeInterval": {"@id": "schema:datasetTimeInterval", "@type": "DateTime"},
"dateCreated": {"@id": "schema:dateCreated", "@type": "Date"},
"dateDeleted": {"@id": "schema:dateDeleted", "@type": "Date"},
"dateIssued": {"@id": "schema:dateIssued", "@type": "Date"},
"dateModified": {"@id": "schema:dateModified", "@type": "Date"},
"datePosted": {"@id": "schema:datePosted", "@type": "Date"},
"datePublished": {"@id": "schema:datePublished", "@type": "Date"},
"dateRead": {"@id": "schema:dateRead", "@type": "Date"},
"dateReceived": {"@id": "schema:dateReceived", "@type": "DateTime"},
"dateSent": {"@id": "schema:dateSent", "@type": "DateTime"},
"dateVehicleFirstRegistered": {
"@id": "schema:dateVehicleFirstRegistered",
"@type": "Date",
},
"dateline": {"@id": "schema:dateline"},
"dayOfWeek": {"@id": "schema:dayOfWeek"},
"deathDate": {"@id": "schema:deathDate", "@type": "Date"},
"deathPlace": {"@id": "schema:deathPlace"},
"defaultValue": {"@id": "schema:defaultValue"},
"deliveryAddress": {"@id": "schema:deliveryAddress"},
"deliveryLeadTime": {"@id": "schema:deliveryLeadTime"},
"deliveryMethod": {"@id": "schema:deliveryMethod"},
"deliveryStatus": {"@id": "schema:deliveryStatus"},
"department": {"@id": "schema:department"},
"departureAirport": {"@id": "schema:departureAirport"},
"departureBusStop": {"@id": "schema:departureBusStop"},
"departureGate": {"@id": "schema:departureGate"},
"departurePlatform": {"@id": "schema:departurePlatform"},
"departureStation": {"@id": "schema:departureStation"},
"departureTerminal": {"@id": "schema:departureTerminal"},
"departureTime": {"@id": "schema:departureTime", "@type": "DateTime"},
"dependencies": {"@id": "schema:dependencies"},
"depth": {"@id": "schema:depth"},
"description": {"@id": "schema:description"},
"device": {"@id": "schema:device"},
"diagnosis": {"@id": "schema:diagnosis"},
"diagram": {"@id": "schema:diagram"},
"diet": {"@id": "schema:diet"},
"dietFeatures": {"@id": "schema:dietFeatures"},
"differentialDiagnosis": {"@id": "schema:differentialDiagnosis"},
"director": {"@id": "schema:director"},
"directors": {"@id": "schema:directors"},
"disambiguatingDescription": {"@id": "schema:disambiguatingDescription"},
"discount": {"@id": "schema:discount"},
"discountCode": {"@id": "schema:discountCode"},
"discountCurrency": {"@id": "schema:discountCurrency"},
"discusses": {"@id": "schema:discusses"},
"discussionUrl": {"@id": "schema:discussionUrl", "@type": "@id"},
"diseasePreventionInfo": {"@id": "schema:diseasePreventionInfo", "@type": "@id"},
"diseaseSpreadStatistics": {
"@id": "schema:diseaseSpreadStatistics",
"@type": "@id",
},
"dissolutionDate": {"@id": "schema:dissolutionDate", "@type": "Date"},
"distance": {"@id": "schema:distance"},
"distinguishingSign": {"@id": "schema:distinguishingSign"},
"distribution": {"@id": "schema:distribution"},
"diversityPolicy": {"@id": "schema:diversityPolicy", "@type": "@id"},
"diversityStaffingReport": {
"@id": "schema:diversityStaffingReport",
"@type": "@id",
},
"documentation": {"@id": "schema:documentation", "@type": "@id"},
"domainIncludes": {"@id": "schema:domainIncludes"},
"domiciledMortgage": {"@id": "schema:domiciledMortgage"},
"doorTime": {"@id": "schema:doorTime", "@type": "DateTime"},
"dosageForm": {"@id": "schema:dosageForm"},
"doseSchedule": {"@id": "schema:doseSchedule"},
"doseUnit": {"@id": "schema:doseUnit"},
"doseValue": {"@id": "schema:doseValue"},
"downPayment": {"@id": "schema:downPayment"},
"downloadUrl": {"@id": "schema:downloadUrl", "@type": "@id"},
"downvoteCount": {"@id": "schema:downvoteCount"},
"drainsTo": {"@id": "schema:drainsTo"},
"driveWheelConfiguration": {"@id": "schema:driveWheelConfiguration"},
"dropoffLocation": {"@id": "schema:dropoffLocation"},
"dropoffTime": {"@id": "schema:dropoffTime", "@type": "DateTime"},
"drug": {"@id": "schema:drug"},
"drugClass": {"@id": "schema:drugClass"},
"drugUnit": {"@id": "schema:drugUnit"},
"duns": {"@id": "schema:duns"},
"duplicateTherapy": {"@id": "schema:duplicateTherapy"},
"duration": {"@id": "schema:duration"},
"durationOfWarranty": {"@id": "schema:durationOfWarranty"},
"duringMedia": {"@id": "schema:duringMedia", "@type": "@id"},
"earlyPrepaymentPenalty": {"@id": "schema:earlyPrepaymentPenalty"},
"editor": {"@id": "schema:editor"},
"educationRequirements": {"@id": "schema:educationRequirements"},
"educationalAlignment": {"@id": "schema:educationalAlignment"},
"educationalCredentialAwarded": {"@id": "schema:educationalCredentialAwarded"},
"educationalFramework": {"@id": "schema:educationalFramework"},
"educationalLevel": {"@id": "schema:educationalLevel"},
"educationalProgramMode": {"@id": "schema:educationalProgramMode"},
"educationalRole": {"@id": "schema:educationalRole"},
"educationalUse": {"@id": "schema:educationalUse"},
"elevation": {"@id": "schema:elevation"},
"eligibleCustomerType": {"@id": "schema:eligibleCustomerType"},
"eligibleDuration": {"@id": "schema:eligibleDuration"},
"eligibleQuantity": {"@id": "schema:eligibleQuantity"},
"eligibleRegion": {"@id": "schema:eligibleRegion"},
"eligibleTransactionVolume": {"@id": "schema:eligibleTransactionVolume"},
"email": {"@id": "schema:email"},
"embedUrl": {"@id": "schema:embedUrl", "@type": "@id"},
"emissionsCO2": {"@id": "schema:emissionsCO2"},
"employee": {"@id": "schema:employee"},
"employees": {"@id": "schema:employees"},
"employerOverview": {"@id": "schema:employerOverview"},
"employmentType": {"@id": "schema:employmentType"},
"employmentUnit": {"@id": "schema:employmentUnit"},
"encodesCreativeWork": {"@id": "schema:encodesCreativeWork"},
"encoding": {"@id": "schema:encoding"},
"encodingFormat": {"@id": "schema:encodingFormat"},
"encodingType": {"@id": "schema:encodingType"},
"encodings": {"@id": "schema:encodings"},
"endDate": {"@id": "schema:endDate", "@type": "Date"},
"endOffset": {"@id": "schema:endOffset"},
"endTime": {"@id": "schema:endTime", "@type": "DateTime"},
"endorsee": {"@id": "schema:endorsee"},
"endorsers": {"@id": "schema:endorsers"},
"engineDisplacement": {"@id": "schema:engineDisplacement"},
"enginePower": {"@id": "schema:enginePower"},
"engineType": {"@id": "schema:engineType"},
"entertainmentBusiness": {"@id": "schema:entertainmentBusiness"},
"epidemiology": {"@id": "schema:epidemiology"},
"episode": {"@id": "schema:episode"},
"episodeNumber": {"@id": "schema:episodeNumber"},
"episodes": {"@id": "schema:episodes"},
"equal": {"@id": "schema:equal"},
"error": {"@id": "schema:error"},
"estimatedCost": {"@id": "schema:estimatedCost"},
"estimatedFlightDuration": {"@id": "schema:estimatedFlightDuration"},
"estimatedSalary": {"@id": "schema:estimatedSalary"},
"estimatesRiskOf": {"@id": "schema:estimatesRiskOf"},
"ethicsPolicy": {"@id": "schema:ethicsPolicy", "@type": "@id"},
"event": {"@id": "schema:event"},
"eventAttendanceMode": {"@id": "schema:eventAttendanceMode"},
"eventSchedule": {"@id": "schema:eventSchedule"},
"eventStatus": {"@id": "schema:eventStatus"},
"events": {"@id": "schema:events"},
"evidenceLevel": {"@id": "schema:evidenceLevel"},
"evidenceOrigin": {"@id": "schema:evidenceOrigin"},
"exampleOfWork": {"@id": "schema:exampleOfWork"},
"exceptDate": {"@id": "schema:exceptDate", "@type": "Date"},
"exchangeRateSpread": {"@id": "schema:exchangeRateSpread"},
"executableLibraryName": {"@id": "schema:executableLibraryName"},
"exerciseCourse": {"@id": "schema:exerciseCourse"},
"exercisePlan": {"@id": "schema:exercisePlan"},
"exerciseRelatedDiet": {"@id": "schema:exerciseRelatedDiet"},
"exerciseType": {"@id": "schema:exerciseType"},
"exifData": {"@id": "schema:exifData"},
"expectedArrivalFrom": {"@id": "schema:expectedArrivalFrom", "@type": "Date"},
"expectedArrivalUntil": {"@id": "schema:expectedArrivalUntil", "@type": "Date"},
"expectedPrognosis": {"@id": "schema:expectedPrognosis"},
"expectsAcceptanceOf": {"@id": "schema:expectsAcceptanceOf"},
"experienceRequirements": {"@id": "schema:experienceRequirements"},
"expertConsiderations": {"@id": "schema:expertConsiderations"},
"expires": {"@id": "schema:expires", "@type": "Date"},
"familyName": {"@id": "schema:familyName"},
"fatContent": {"@id": "schema:fatContent"},
"faxNumber": {"@id": "schema:faxNumber"},
"featureList": {"@id": "schema:featureList"},
"feesAndCommissionsSpecification": {
"@id": "schema:feesAndCommissionsSpecification"
},
"fiberContent": {"@id": "schema:fiberContent"},
"fileFormat": {"@id": "schema:fileFormat"},
"fileSize": {"@id": "schema:fileSize"},
"financialAidEligible": {"@id": "schema:financialAidEligible"},
"firstAppearance": {"@id": "schema:firstAppearance"},
"firstPerformance": {"@id": "schema:firstPerformance"},
"flightDistance": {"@id": "schema:flightDistance"},
"flightNumber": {"@id": "schema:flightNumber"},
"floorLevel": {"@id": "schema:floorLevel"},
"floorLimit": {"@id": "schema:floorLimit"},
"floorSize": {"@id": "schema:floorSize"},
"followee": {"@id": "schema:followee"},
"follows": {"@id": "schema:follows"},
"followup": {"@id": "schema:followup"},
"foodEstablishment": {"@id": "schema:foodEstablishment"},
"foodEvent": {"@id": "schema:foodEvent"},
"foodWarning": {"@id": "schema:foodWarning"},
"founder": {"@id": "schema:founder"},
"founders": {"@id": "schema:founders"},
"foundingDate": {"@id": "schema:foundingDate", "@type": "Date"},
"foundingLocation": {"@id": "schema:foundingLocation"},
"free": {"@id": "schema:free"},
"frequency": {"@id": "schema:frequency"},
"fromLocation": {"@id": "schema:fromLocation"},
"fuelCapacity": {"@id": "schema:fuelCapacity"},
"fuelConsumption": {"@id": "schema:fuelConsumption"},
"fuelEfficiency": {"@id": "schema:fuelEfficiency"},
"fuelType": {"@id": "schema:fuelType"},
"functionalClass": {"@id": "schema:functionalClass"},
"fundedItem": {"@id": "schema:fundedItem"},
"funder": {"@id": "schema:funder"},
"game": {"@id": "schema:game"},
"gameItem": {"@id": "schema:gameItem"},
"gameLocation": {"@id": "schema:gameLocation", "@type": "@id"},
"gamePlatform": {"@id": "schema:gamePlatform"},
"gameServer": {"@id": "schema:gameServer"},
"gameTip": {"@id": "schema:gameTip"},
"gender": {"@id": "schema:gender"},
"genre": {"@id": "schema:genre"},
"geo": {"@id": "schema:geo"},
"geoContains": {"@id": "schema:geoContains"},
"geoCoveredBy": {"@id": "schema:geoCoveredBy"},
"geoCovers": {"@id": "schema:geoCovers"},
"geoCrosses": {"@id": "schema:geoCrosses"},
"geoDisjoint": {"@id": "schema:geoDisjoint"},
"geoEquals": {"@id": "schema:geoEquals"},
"geoIntersects": {"@id": "schema:geoIntersects"},
"geoMidpoint": {"@id": "schema:geoMidpoint"},
"geoOverlaps": {"@id": "schema:geoOverlaps"},
"geoRadius": {"@id": "schema:geoRadius"},
"geoTouches": {"@id": "schema:geoTouches"},
"geoWithin": {"@id": "schema:geoWithin"},
"geographicArea": {"@id": "schema:geographicArea"},
"gettingTestedInfo": {"@id": "schema:gettingTestedInfo", "@type": "@id"},
"givenName": {"@id": "schema:givenName"},
"globalLocationNumber": {"@id": "schema:globalLocationNumber"},
"gracePeriod": {"@id": "schema:gracePeriod"},
"grantee": {"@id": "schema:grantee"},
"greater": {"@id": "schema:greater"},
"greaterOrEqual": {"@id": "schema:greaterOrEqual"},
"gtin": {"@id": "schema:gtin"},
"gtin12": {"@id": "schema:gtin12"},
"gtin13": {"@id": "schema:gtin13"},
"gtin14": {"@id": "schema:gtin14"},
"gtin8": {"@id": "schema:gtin8"},
"guideline": {"@id": "schema:guideline"},
"guidelineDate": {"@id": "schema:guidelineDate", "@type": "Date"},
"guidelineSubject": {"@id": "schema:guidelineSubject"},
"hasBroadcastChannel": {"@id": "schema:hasBroadcastChannel"},
"hasCategoryCode": {"@id": "schema:hasCategoryCode"},
"hasCourseInstance": {"@id": "schema:hasCourseInstance"},
"hasCredential": {"@id": "schema:hasCredential"},
"hasDefinedTerm": {"@id": "schema:hasDefinedTerm"},
"hasDeliveryMethod": {"@id": "schema:hasDeliveryMethod"},
"hasDigitalDocumentPermission": {"@id": "schema:hasDigitalDocumentPermission"},
"hasDriveThroughService": {"@id": "schema:hasDriveThroughService"},
"hasHealthAspect": {"@id": "schema:hasHealthAspect"},
"hasMap": {"@id": "schema:hasMap", "@type": "@id"},
"hasMenu": {"@id": "schema:hasMenu"},
"hasMenuItem": {"@id": "schema:hasMenuItem"},
"hasMenuSection": {"@id": "schema:hasMenuSection"},
"hasMerchantReturnPolicy": {"@id": "schema:hasMerchantReturnPolicy"},
"hasOccupation": {"@id": "schema:hasOccupation"},
"hasOfferCatalog": {"@id": "schema:hasOfferCatalog"},
"hasPOS": {"@id": "schema:hasPOS"},
"hasPart": {"@id": "schema:hasPart"},
"hasProductReturnPolicy": {"@id": "schema:hasProductReturnPolicy"},
"headline": {"@id": "schema:headline"},
"healthCondition": {"@id": "schema:healthCondition"},
"healthPlanCoinsuranceOption": {"@id": "schema:healthPlanCoinsuranceOption"},
"healthPlanCoinsuranceRate": {"@id": "schema:healthPlanCoinsuranceRate"},
"healthPlanCopay": {"@id": "schema:healthPlanCopay"},
"healthPlanCopayOption": {"@id": "schema:healthPlanCopayOption"},
"healthPlanCostSharing": {"@id": "schema:healthPlanCostSharing"},
"healthPlanDrugOption": {"@id": "schema:healthPlanDrugOption"},
"healthPlanDrugTier": {"@id": "schema:healthPlanDrugTier"},
"healthPlanId": {"@id": "schema:healthPlanId"},
"healthPlanMarketingUrl": {"@id": "schema:healthPlanMarketingUrl", "@type": "@id"},
"healthPlanNetworkId": {"@id": "schema:healthPlanNetworkId"},
"healthPlanNetworkTier": {"@id": "schema:healthPlanNetworkTier"},
"healthPlanPharmacyCategory": {"@id": "schema:healthPlanPharmacyCategory"},
"height": {"@id": "schema:height"},
"highPrice": {"@id": "schema:highPrice"},
"hiringOrganization": {"@id": "schema:hiringOrganization"},
"holdingArchive": {"@id": "schema:holdingArchive"},
"homeLocation": {"@id": "schema:homeLocation"},
"homeTeam": {"@id": "schema:homeTeam"},
"honorificPrefix": {"@id": "schema:honorificPrefix"},
"honorificSuffix": {"@id": "schema:honorificSuffix"},
"hospitalAffiliation": {"@id": "schema:hospitalAffiliation"},
"hostingOrganization": {"@id": "schema:hostingOrganization"},
"hoursAvailable": {"@id": "schema:hoursAvailable"},
"howPerformed": {"@id": "schema:howPerformed"},
"iataCode": {"@id": "schema:iataCode"},
"icaoCode": {"@id": "schema:icaoCode"},
"identifier": {"@id": "schema:identifier"},
"identifyingExam": {"@id": "schema:identifyingExam"},
"identifyingTest": {"@id": "schema:identifyingTest"},
"illustrator": {"@id": "schema:illustrator"},
"image": {"@id": "schema:image", "@type": "@id"},
"imagingTechnique": {"@id": "schema:imagingTechnique"},
"inAlbum": {"@id": "schema:inAlbum"},
"inBroadcastLineup": {"@id": "schema:inBroadcastLineup"},
"inCodeSet": {"@id": "schema:inCodeSet", "@type": "@id"},
"inDefinedTermSet": {"@id": "schema:inDefinedTermSet", "@type": "@id"},
"inLanguage": {"@id": "schema:inLanguage"},
"inPlaylist": {"@id": "schema:inPlaylist"},
"inStoreReturnsOffered": {"@id": "schema:inStoreReturnsOffered"},
"inSupportOf": {"@id": "schema:inSupportOf"},
"incentiveCompensation": {"@id": "schema:incentiveCompensation"},
"incentives": {"@id": "schema:incentives"},
"includedComposition": {"@id": "schema:includedComposition"},
"includedDataCatalog": {"@id": "schema:includedDataCatalog"},
"includedInDataCatalog": {"@id": "schema:includedInDataCatalog"},
"includedInHealthInsurancePlan": {"@id": "schema:includedInHealthInsurancePlan"},
"includedRiskFactor": {"@id": "schema:includedRiskFactor"},
"includesAttraction": {"@id": "schema:includesAttraction"},
"includesHealthPlanFormulary": {"@id": "schema:includesHealthPlanFormulary"},
"includesHealthPlanNetwork": {"@id": "schema:includesHealthPlanNetwork"},
"includesObject": {"@id": "schema:includesObject"},
"increasesRiskOf": {"@id": "schema:increasesRiskOf"},
"industry": {"@id": "schema:industry"},
"ineligibleRegion": {"@id": "schema:ineligibleRegion"},
"infectiousAgent": {"@id": "schema:infectiousAgent"},
"infectiousAgentClass": {"@id": "schema:infectiousAgentClass"},
"ingredients": {"@id": "schema:ingredients"},
"inker": {"@id": "schema:inker"},
"insertion": {"@id": "schema:insertion"},
"installUrl": {"@id": "schema:installUrl", "@type": "@id"},
"instructor": {"@id": "schema:instructor"},
"instrument": {"@id": "schema:instrument"},
"intensity": {"@id": "schema:intensity"},
"interactingDrug": {"@id": "schema:interactingDrug"},
"interactionCount": {"@id": "schema:interactionCount"},
"interactionService": {"@id": "schema:interactionService"},
"interactionStatistic": {"@id": "schema:interactionStatistic"},
"interactionType": {"@id": "schema:interactionType"},
"interactivityType": {"@id": "schema:interactivityType"},
"interestRate": {"@id": "schema:interestRate"},
"inventoryLevel": {"@id": "schema:inventoryLevel"},
"inverseOf": {"@id": "schema:inverseOf"},
"isAcceptingNewPatients": {"@id": "schema:isAcceptingNewPatients"},
"isAccessibleForFree": {"@id": "schema:isAccessibleForFree"},
"isAccessoryOrSparePartFor": {"@id": "schema:isAccessoryOrSparePartFor"},
"isAvailableGenerically": {"@id": "schema:isAvailableGenerically"},
"isBasedOn": {"@id": "schema:isBasedOn", "@type": "@id"},
"isBasedOnUrl": {"@id": "schema:isBasedOnUrl", "@type": "@id"},
"isConsumableFor": {"@id": "schema:isConsumableFor"},
"isFamilyFriendly": {"@id": "schema:isFamilyFriendly"},
"isGift": {"@id": "schema:isGift"},
"isLiveBroadcast": {"@id": "schema:isLiveBroadcast"},
"isPartOf": {"@id": "schema:isPartOf", "@type": "@id"},
"isPlanForApartment": {"@id": "schema:isPlanForApartment"},
"isProprietary": {"@id": "schema:isProprietary"},
"isRelatedTo": {"@id": "schema:isRelatedTo"},
"isResizable": {"@id": "schema:isResizable"},
"isSimilarTo": {"@id": "schema:isSimilarTo"},
"isVariantOf": {"@id": "schema:isVariantOf"},
"isbn": {"@id": "schema:isbn"},
"isicV4": {"@id": "schema:isicV4"},
"isrcCode": {"@id": "schema:isrcCode"},
"issn": {"@id": "schema:issn"},
"issueNumber": {"@id": "schema:issueNumber"},
"issuedBy": {"@id": "schema:issuedBy"},
"issuedThrough": {"@id": "schema:issuedThrough"},
"iswcCode": {"@id": "schema:iswcCode"},
"item": {"@id": "schema:item"},
"itemCondition": {"@id": "schema:itemCondition"},
"itemListElement": {"@id": "schema:itemListElement"},
"itemListOrder": {"@id": "schema:itemListOrder"},
"itemLocation": {"@id": "schema:itemLocation"},
"itemOffered": {"@id": "schema:itemOffered"},
"itemReviewed": {"@id": "schema:itemReviewed"},
"itemShipped": {"@id": "schema:itemShipped"},
"itinerary": {"@id": "schema:itinerary"},
"jobBenefits": {"@id": "schema:jobBenefits"},
"jobImmediateStart": {"@id": "schema:jobImmediateStart"},
"jobLocation": {"@id": "schema:jobLocation"},
"jobLocationType": {"@id": "schema:jobLocationType"},
"jobStartDate": {"@id": "schema:jobStartDate"},
"jobTitle": {"@id": "schema:jobTitle"},
"keywords": {"@id": "schema:keywords"},
"knownVehicleDamages": {"@id": "schema:knownVehicleDamages"},
"knows": {"@id": "schema:knows"},
"knowsAbout": {"@id": "schema:knowsAbout"},
"knowsLanguage": {"@id": "schema:knowsLanguage"},
"labelDetails": {"@id": "schema:labelDetails", "@type": "@id"},
"landlord": {"@id": "schema:landlord"},
"language": {"@id": "schema:language"},
"lastReviewed": {"@id": "schema:lastReviewed", "@type": "Date"},
"latitude": {"@id": "schema:latitude"},
"learningResourceType": {"@id": "schema:learningResourceType"},
"leaseLength": {"@id": "schema:leaseLength"},
"legalName": {"@id": "schema:legalName"},
"legalStatus": {"@id": "schema:legalStatus"},
"legislationApplies": {"@id": "schema:legislationApplies"},
"legislationChanges": {"@id": "schema:legislationChanges"},
"legislationConsolidates": {"@id": "schema:legislationConsolidates"},
"legislationDate": {"@id": "schema:legislationDate", "@type": "Date"},
"legislationDateVersion": {
"@id": "schema:legislationDateVersion",
"@type": "Date",
},
"legislationIdentifier": {"@id": "schema:legislationIdentifier"},
"legislationJurisdiction": {"@id": "schema:legislationJurisdiction"},
"legislationLegalForce": {"@id": "schema:legislationLegalForce"},
"legislationLegalValue": {"@id": "schema:legislationLegalValue"},
"legislationPassedBy": {"@id": "schema:legislationPassedBy"},
"legislationResponsible": {"@id": "schema:legislationResponsible"},
"legislationTransposes": {"@id": "schema:legislationTransposes"},
"legislationType": {"@id": "schema:legislationType"},
"leiCode": {"@id": "schema:leiCode"},
"lender": {"@id": "schema:lender"},
"lesser": {"@id": "schema:lesser"},
"lesserOrEqual": {"@id": "schema:lesserOrEqual"},
"letterer": {"@id": "schema:letterer"},
"license": {"@id": "schema:license", "@type": "@id"},
"line": {"@id": "schema:line"},
"linkRelationship": {"@id": "schema:linkRelationship"},
"liveBlogUpdate": {"@id": "schema:liveBlogUpdate"},
"loanMortgageMandateAmount": {"@id": "schema:loanMortgageMandateAmount"},
"loanPaymentAmount": {"@id": "schema:loanPaymentAmount"},
"loanPaymentFrequency": {"@id": "schema:loanPaymentFrequency"},
"loanRepaymentForm": {"@id": "schema:loanRepaymentForm"},
"loanTerm": {"@id": "schema:loanTerm"},
"loanType": {"@id": "schema:loanType"},
"location": {"@id": "schema:location"},
"locationCreated": {"@id": "schema:locationCreated"},
"lodgingUnitDescription": {"@id": "schema:lodgingUnitDescription"},
"lodgingUnitType": {"@id": "schema:lodgingUnitType"},
"logo": {"@id": "schema:logo", "@type": "@id"},
"longitude": {"@id": "schema:longitude"},
"loser": {"@id": "schema:loser"},
"lowPrice": {"@id": "schema:lowPrice"},
"lyricist": {"@id": "schema:lyricist"},
"lyrics": {"@id": "schema:lyrics"},
"mainContentOfPage": {"@id": "schema:mainContentOfPage"},
"mainEntity": {"@id": "schema:mainEntity"},
"mainEntityOfPage": {"@id": "schema:mainEntityOfPage", "@type": "@id"},
"maintainer": {"@id": "schema:maintainer"},
"makesOffer": {"@id": "schema:makesOffer"},
"manufacturer": {"@id": "schema:manufacturer"},
"map": {"@id": "schema:map", "@type": "@id"},
"mapType": {"@id": "schema:mapType"},
"maps": {"@id": "schema:maps", "@type": "@id"},
"marginOfError": {"@id": "schema:marginOfError", "@type": "DateTime"},
"masthead": {"@id": "schema:masthead", "@type": "@id"},
"material": {"@id": "schema:material"},
"materialExtent": {"@id": "schema:materialExtent"},
"maxPrice": {"@id": "schema:maxPrice"},
"maxValue": {"@id": "schema:maxValue"},
"maximumAttendeeCapacity": {"@id": "schema:maximumAttendeeCapacity"},
"maximumEnrollment": {"@id": "schema:maximumEnrollment"},
"maximumIntake": {"@id": "schema:maximumIntake"},
"maximumPhysicalAttendeeCapacity": {
"@id": "schema:maximumPhysicalAttendeeCapacity"
},
"maximumVirtualAttendeeCapacity": {"@id": "schema:maximumVirtualAttendeeCapacity"},
"mealService": {"@id": "schema:mealService"},
"measuredProperty": {"@id": "schema:measuredProperty"},
"measuredValue": {"@id": "schema:measuredValue"},
"measurementTechnique": {"@id": "schema:measurementTechnique"},
"mechanismOfAction": {"@id": "schema:mechanismOfAction"},
"mediaAuthenticityCategory": {"@id": "schema:mediaAuthenticityCategory"},
"median": {"@id": "schema:median"},
"medicalSpecialty": {"@id": "schema:medicalSpecialty"},
"medicineSystem": {"@id": "schema:medicineSystem"},
"meetsEmissionStandard": {"@id": "schema:meetsEmissionStandard"},
"member": {"@id": "schema:member"},
"memberOf": {"@id": "schema:memberOf"},
"members": {"@id": "schema:members"},
"membershipNumber": {"@id": "schema:membershipNumber"},
"membershipPointsEarned": {"@id": "schema:membershipPointsEarned"},
"memoryRequirements": {"@id": "schema:memoryRequirements"},
"mentions": {"@id": "schema:mentions"},
"menu": {"@id": "schema:menu"},
"menuAddOn": {"@id": "schema:menuAddOn"},
"merchant": {"@id": "schema:merchant"},
"merchantReturnDays": {"@id": "schema:merchantReturnDays"},
"merchantReturnLink": {"@id": "schema:merchantReturnLink", "@type": "@id"},
"messageAttachment": {"@id": "schema:messageAttachment"},
"mileageFromOdometer": {"@id": "schema:mileageFromOdometer"},
"minPrice": {"@id": "schema:minPrice"},
"minValue": {"@id": "schema:minValue"},
"minimumPaymentDue": {"@id": "schema:minimumPaymentDue"},
"missionCoveragePrioritiesPolicy": {
"@id": "schema:missionCoveragePrioritiesPolicy",
"@type": "@id",
},
"model": {"@id": "schema:model"},
"modelDate": {"@id": "schema:modelDate", "@type": "Date"},
"modifiedTime": {"@id": "schema:modifiedTime", "@type": "DateTime"},
"monthlyMinimumRepaymentAmount": {"@id": "schema:monthlyMinimumRepaymentAmount"},
"mpn": {"@id": "schema:mpn"},
"multipleValues": {"@id": "schema:multipleValues"},
"muscleAction": {"@id": "schema:muscleAction"},
"musicArrangement": {"@id": "schema:musicArrangement"},
"musicBy": {"@id": "schema:musicBy"},
"musicCompositionForm": {"@id": "schema:musicCompositionForm"},
"musicGroupMember": {"@id": "schema:musicGroupMember"},
"musicReleaseFormat": {"@id": "schema:musicReleaseFormat"},
"musicalKey": {"@id": "schema:musicalKey"},
"naics": {"@id": "schema:naics"},
"name": {"@id": "schema:name"},
"namedPosition": {"@id": "schema:namedPosition"},
"nationality": {"@id": "schema:nationality"},
"naturalProgression": {"@id": "schema:naturalProgression"},
"nerve": {"@id": "schema:nerve"},
"nerveMotor": {"@id": "schema:nerveMotor"},
"netWorth": {"@id": "schema:netWorth"},
"newsUpdatesAndGuidelines": {
"@id": "schema:newsUpdatesAndGuidelines",
"@type": "@id",
},
"nextItem": {"@id": "schema:nextItem"},
"noBylinesPolicy": {"@id": "schema:noBylinesPolicy", "@type": "@id"},
"nonEqual": {"@id": "schema:nonEqual"},
"nonProprietaryName": {"@id": "schema:nonProprietaryName"},
"normalRange": {"@id": "schema:normalRange"},
"nsn": {"@id": "schema:nsn"},
"numAdults": {"@id": "schema:numAdults"},
"numChildren": {"@id": "schema:numChildren"},
"numConstraints": {"@id": "schema:numConstraints"},
"numTracks": {"@id": "schema:numTracks"},
"numberOfAccommodationUnits": {"@id": "schema:numberOfAccommodationUnits"},
"numberOfAirbags": {"@id": "schema:numberOfAirbags"},
"numberOfAvailableAccommodationUnits": {
"@id": "schema:numberOfAvailableAccommodationUnits"
},
"numberOfAxles": {"@id": "schema:numberOfAxles"},
"numberOfBathroomsTotal": {"@id": "schema:numberOfBathroomsTotal"},
"numberOfBedrooms": {"@id": "schema:numberOfBedrooms"},
"numberOfBeds": {"@id": "schema:numberOfBeds"},
"numberOfCredits": {"@id": "schema:numberOfCredits"},
"numberOfDoors": {"@id": "schema:numberOfDoors"},
"numberOfEmployees": {"@id": "schema:numberOfEmployees"},
"numberOfEpisodes": {"@id": "schema:numberOfEpisodes"},
"numberOfForwardGears": {"@id": "schema:numberOfForwardGears"},
"numberOfFullBathrooms": {"@id": "schema:numberOfFullBathrooms"},
"numberOfItems": {"@id": "schema:numberOfItems"},
"numberOfLoanPayments": {"@id": "schema:numberOfLoanPayments"},
"numberOfPages": {"@id": "schema:numberOfPages"},
"numberOfPartialBathrooms": {"@id": "schema:numberOfPartialBathrooms"},
"numberOfPlayers": {"@id": "schema:numberOfPlayers"},
"numberOfPreviousOwners": {"@id": "schema:numberOfPreviousOwners"},
"numberOfRooms": {"@id": "schema:numberOfRooms"},
"numberOfSeasons": {"@id": "schema:numberOfSeasons"},
"numberedPosition": {"@id": "schema:numberedPosition"},
"nutrition": {"@id": "schema:nutrition"},
"object": {"@id": "schema:object"},
"observationDate": {"@id": "schema:observationDate", "@type": "DateTime"},
"observedNode": {"@id": "schema:observedNode"},
"occupancy": {"@id": "schema:occupancy"},
"occupationLocation": {"@id": "schema:occupationLocation"},
"occupationalCategory": {"@id": "schema:occupationalCategory"},
"occupationalCredentialAwarded": {"@id": "schema:occupationalCredentialAwarded"},
"offerCount": {"@id": "schema:offerCount"},
"offeredBy": {"@id": "schema:offeredBy"},
"offers": {"@id": "schema:offers"},
"offersPrescriptionByMail": {"@id": "schema:offersPrescriptionByMail"},
"openingHours": {"@id": "schema:openingHours"},
"openingHoursSpecification": {"@id": "schema:openingHoursSpecification"},
"opens": {"@id": "schema:opens"},
"operatingSystem": {"@id": "schema:operatingSystem"},
"opponent": {"@id": "schema:opponent"},
"option": {"@id": "schema:option"},
"orderDate": {"@id": "schema:orderDate", "@type": "Date"},
"orderDelivery": {"@id": "schema:orderDelivery"},
"orderItemNumber": {"@id": "schema:orderItemNumber"},
"orderItemStatus": {"@id": "schema:orderItemStatus"},
"orderNumber": {"@id": "schema:orderNumber"},
"orderQuantity": {"@id": "schema:orderQuantity"},
"orderStatus": {"@id": "schema:orderStatus"},
"orderedItem": {"@id": "schema:orderedItem"},
"organizer": {"@id": "schema:organizer"},
"originAddress": {"@id": "schema:originAddress"},
"originatesFrom": {"@id": "schema:originatesFrom"},
"overdosage": {"@id": "schema:overdosage"},
"ownedFrom": {"@id": "schema:ownedFrom", "@type": "DateTime"},
"ownedThrough": {"@id": "schema:ownedThrough", "@type": "DateTime"},
"ownershipFundingInfo": {"@id": "schema:ownershipFundingInfo"},
"owns": {"@id": "schema:owns"},
"pageEnd": {"@id": "schema:pageEnd"},
"pageStart": {"@id": "schema:pageStart"},
"pagination": {"@id": "schema:pagination"},
"parent": {"@id": "schema:parent"},
"parentItem": {"@id": "schema:parentItem"},
"parentOrganization": {"@id": "schema:parentOrganization"},
"parentService": {"@id": "schema:parentService"},
"parents": {"@id": "schema:parents"},
"partOfEpisode": {"@id": "schema:partOfEpisode"},
"partOfInvoice": {"@id": "schema:partOfInvoice"},
"partOfOrder": {"@id": "schema:partOfOrder"},
"partOfSeason": {"@id": "schema:partOfSeason"},
"partOfSeries": {"@id": "schema:partOfSeries"},
"partOfSystem": {"@id": "schema:partOfSystem"},
"partOfTVSeries": {"@id": "schema:partOfTVSeries"},
"partOfTrip": {"@id": "schema:partOfTrip"},
"participant": {"@id": "schema:participant"},
"partySize": {"@id": "schema:partySize"},
"passengerPriorityStatus": {"@id": "schema:passengerPriorityStatus"},
"passengerSequenceNumber": {"@id": "schema:passengerSequenceNumber"},
"pathophysiology": {"@id": "schema:pathophysiology"},
"payload": {"@id": "schema:payload"},
"paymentAccepted": {"@id": "schema:paymentAccepted"},
"paymentDue": {"@id": "schema:paymentDue", "@type": "DateTime"},
"paymentDueDate": {"@id": "schema:paymentDueDate", "@type": "Date"},
"paymentMethod": {"@id": "schema:paymentMethod"},
"paymentMethodId": {"@id": "schema:paymentMethodId"},
"paymentStatus": {"@id": "schema:paymentStatus"},
"paymentUrl": {"@id": "schema:paymentUrl", "@type": "@id"},
"penciler": {"@id": "schema:penciler"},
"percentile10": {"@id": "schema:percentile10"},
"percentile25": {"@id": "schema:percentile25"},
"percentile75": {"@id": "schema:percentile75"},
"percentile90": {"@id": "schema:percentile90"},
"performTime": {"@id": "schema:performTime"},
"performer": {"@id": "schema:performer"},
"performerIn": {"@id": "schema:performerIn"},
"performers": {"@id": "schema:performers"},
"permissionType": {"@id": "schema:permissionType"},
"permissions": {"@id": "schema:permissions"},
"permitAudience": {"@id": "schema:permitAudience"},
"permittedUsage": {"@id": "schema:permittedUsage"},
"petsAllowed": {"@id": "schema:petsAllowed"},
"phoneticText": {"@id": "schema:phoneticText"},
"photo": {"@id": "schema:photo"},
"photos": {"@id": "schema:photos"},
"physicalRequirement": {"@id": "schema:physicalRequirement"},
"physiologicalBenefits": {"@id": "schema:physiologicalBenefits"},
"pickupLocation": {"@id": "schema:pickupLocation"},
"pickupTime": {"@id": "schema:pickupTime", "@type": "DateTime"},
"playMode": {"@id": "schema:playMode"},
"playerType": {"@id": "schema:playerType"},
"playersOnline": {"@id": "schema:playersOnline"},
"polygon": {"@id": "schema:polygon"},
"populationType": {"@id": "schema:populationType"},
"position": {"@id": "schema:position"},
"possibleComplication": {"@id": "schema:possibleComplication"},
"possibleTreatment": {"@id": "schema:possibleTreatment"},
"postOfficeBoxNumber": {"@id": "schema:postOfficeBoxNumber"},
"postOp": {"@id": "schema:postOp"},
"postalCode": {"@id": "schema:postalCode"},
"potentialAction": {"@id": "schema:potentialAction"},
"preOp": {"@id": "schema:preOp"},
"predecessorOf": {"@id": "schema:predecessorOf"},
"pregnancyCategory": {"@id": "schema:pregnancyCategory"},
"pregnancyWarning": {"@id": "schema:pregnancyWarning"},
"prepTime": {"@id": "schema:prepTime"},
"preparation": {"@id": "schema:preparation"},
"prescribingInfo": {"@id": "schema:prescribingInfo", "@type": "@id"},
"prescriptionStatus": {"@id": "schema:prescriptionStatus"},
"previousItem": {"@id": "schema:previousItem"},
"previousStartDate": {"@id": "schema:previousStartDate", "@type": "Date"},
"price": {"@id": "schema:price"},
"priceComponent": {"@id": "schema:priceComponent"},
"priceCurrency": {"@id": "schema:priceCurrency"},
"priceRange": {"@id": "schema:priceRange"},
"priceSpecification": {"@id": "schema:priceSpecification"},
"priceType": {"@id": "schema:priceType"},
"priceValidUntil": {"@id": "schema:priceValidUntil", "@type": "Date"},
"primaryImageOfPage": {"@id": "schema:primaryImageOfPage"},
"primaryPrevention": {"@id": "schema:primaryPrevention"},
"printColumn": {"@id": "schema:printColumn"},
"printEdition": {"@id": "schema:printEdition"},
"printPage": {"@id": "schema:printPage"},
"printSection": {"@id": "schema:printSection"},
"procedure": {"@id": "schema:procedure"},
"procedureType": {"@id": "schema:procedureType"},
"processingTime": {"@id": "schema:processingTime"},
"processorRequirements": {"@id": "schema:processorRequirements"},
"producer": {"@id": "schema:producer"},
"produces": {"@id": "schema:produces"},
"productID": {"@id": "schema:productID"},
"productReturnDays": {"@id": "schema:productReturnDays"},
"productReturnLink": {"@id": "schema:productReturnLink", "@type": "@id"},
"productSupported": {"@id": "schema:productSupported"},
"productionCompany": {"@id": "schema:productionCompany"},
"productionDate": {"@id": "schema:productionDate", "@type": "Date"},
"proficiencyLevel": {"@id": "schema:proficiencyLevel"},
"programMembershipUsed": {"@id": "schema:programMembershipUsed"},
"programName": {"@id": "schema:programName"},
"programPrerequisites": {"@id": "schema:programPrerequisites"},
"programType": {"@id": "schema:programType"},
"programmingLanguage": {"@id": "schema:programmingLanguage"},
"programmingModel": {"@id": "schema:programmingModel"},
"propertyID": {"@id": "schema:propertyID"},
"proprietaryName": {"@id": "schema:proprietaryName"},
"proteinContent": {"@id": "schema:proteinContent"},
"provider": {"@id": "schema:provider"},
"providerMobility": {"@id": "schema:providerMobility"},
"providesBroadcastService": {"@id": "schema:providesBroadcastService"},
"providesService": {"@id": "schema:providesService"},
"publicAccess": {"@id": "schema:publicAccess"},
"publicTransportClosuresInfo": {
"@id": "schema:publicTransportClosuresInfo",
"@type": "@id",
},
"publication": {"@id": "schema:publication"},
"publicationType": {"@id": "schema:publicationType"},
"publishedBy": {"@id": "schema:publishedBy"},
"publishedOn": {"@id": "schema:publishedOn"},
"publisher": {"@id": "schema:publisher"},
"publisherImprint": {"@id": "schema:publisherImprint"},
"publishingPrinciples": {"@id": "schema:publishingPrinciples", "@type": "@id"},
"purchaseDate": {"@id": "schema:purchaseDate", "@type": "Date"},
"qualifications": {"@id": "schema:qualifications"},
"quarantineGuidelines": {"@id": "schema:quarantineGuidelines", "@type": "@id"},
"query": {"@id": "schema:query"},
"quest": {"@id": "schema:quest"},
"question": {"@id": "schema:question"},
"rangeIncludes": {"@id": "schema:rangeIncludes"},
"ratingCount": {"@id": "schema:ratingCount"},
"ratingExplanation": {"@id": "schema:ratingExplanation"},
"ratingValue": {"@id": "schema:ratingValue"},
"readBy": {"@id": "schema:readBy"},
"readonlyValue": {"@id": "schema:readonlyValue"},
"realEstateAgent": {"@id": "schema:realEstateAgent"},
"recipe": {"@id": "schema:recipe"},
"recipeCategory": {"@id": "schema:recipeCategory"},
"recipeCuisine": {"@id": "schema:recipeCuisine"},
"recipeIngredient": {"@id": "schema:recipeIngredient"},
"recipeInstructions": {"@id": "schema:recipeInstructions"},
"recipeYield": {"@id": "schema:recipeYield"},
"recipient": {"@id": "schema:recipient"},
"recognizedBy": {"@id": "schema:recognizedBy"},
"recognizingAuthority": {"@id": "schema:recognizingAuthority"},
"recommendationStrength": {"@id": "schema:recommendationStrength"},
"recommendedIntake": {"@id": "schema:recommendedIntake"},
"recordLabel": {"@id": "schema:recordLabel"},
"recordedAs": {"@id": "schema:recordedAs"},
"recordedAt": {"@id": "schema:recordedAt"},
"recordedIn": {"@id": "schema:recordedIn"},
"recordingOf": {"@id": "schema:recordingOf"},
"recourseLoan": {"@id": "schema:recourseLoan"},
"referenceQuantity": {"@id": "schema:referenceQuantity"},
"referencesOrder": {"@id": "schema:referencesOrder"},
"refundType": {"@id": "schema:refundType"},
"regionDrained": {"@id": "schema:regionDrained"},
"regionsAllowed": {"@id": "schema:regionsAllowed"},
"relatedAnatomy": {"@id": "schema:relatedAnatomy"},
"relatedCondition": {"@id": "schema:relatedCondition"},
"relatedDrug": {"@id": "schema:relatedDrug"},
"relatedLink": {"@id": "schema:relatedLink", "@type": "@id"},
"relatedStructure": {"@id": "schema:relatedStructure"},
"relatedTherapy": {"@id": "schema:relatedTherapy"},
"relatedTo": {"@id": "schema:relatedTo"},
"releaseDate": {"@id": "schema:releaseDate", "@type": "Date"},
"releaseNotes": {"@id": "schema:releaseNotes"},
"releaseOf": {"@id": "schema:releaseOf"},
"releasedEvent": {"@id": "schema:releasedEvent"},
"relevantOccupation": {"@id": "schema:relevantOccupation"},
"relevantSpecialty": {"@id": "schema:relevantSpecialty"},
"remainingAttendeeCapacity": {"@id": "schema:remainingAttendeeCapacity"},
"renegotiableLoan": {"@id": "schema:renegotiableLoan"},
"repeatCount": {"@id": "schema:repeatCount"},
"repeatFrequency": {"@id": "schema:repeatFrequency"},
"repetitions": {"@id": "schema:repetitions"},
"replacee": {"@id": "schema:replacee"},
"replacer": {"@id": "schema:replacer"},
"replyToUrl": {"@id": "schema:replyToUrl", "@type": "@id"},
"reportNumber": {"@id": "schema:reportNumber"},
"representativeOfPage": {"@id": "schema:representativeOfPage"},
"requiredCollateral": {"@id": "schema:requiredCollateral"},
"requiredGender": {"@id": "schema:requiredGender"},
"requiredMaxAge": {"@id": "schema:requiredMaxAge"},
"requiredMinAge": {"@id": "schema:requiredMinAge"},
"requiredQuantity": {"@id": "schema:requiredQuantity"},
"requirements": {"@id": "schema:requirements"},
"requiresSubscription": {"@id": "schema:requiresSubscription"},
"reservationFor": {"@id": "schema:reservationFor"},
"reservationId": {"@id": "schema:reservationId"},
"reservationStatus": {"@id": "schema:reservationStatus"},
"reservedTicket": {"@id": "schema:reservedTicket"},
"responsibilities": {"@id": "schema:responsibilities"},
"restPeriods": {"@id": "schema:restPeriods"},
"result": {"@id": "schema:result"},
"resultComment": {"@id": "schema:resultComment"},
"resultReview": {"@id": "schema:resultReview"},
"returnFees": {"@id": "schema:returnFees"},
"returnPolicyCategory": {"@id": "schema:returnPolicyCategory"},
"review": {"@id": "schema:review"},
"reviewAspect": {"@id": "schema:reviewAspect"},
"reviewBody": {"@id": "schema:reviewBody"},
"reviewCount": {"@id": "schema:reviewCount"},
"reviewRating": {"@id": "schema:reviewRating"},
"reviewedBy": {"@id": "schema:reviewedBy"},
"reviews": {"@id": "schema:reviews"},
"riskFactor": {"@id": "schema:riskFactor"},
"risks": {"@id": "schema:risks"},
"roleName": {"@id": "schema:roleName"},
"roofLoad": {"@id": "schema:roofLoad"},
"rsvpResponse": {"@id": "schema:rsvpResponse"},
"runsTo": {"@id": "schema:runsTo"},
"runtime": {"@id": "schema:runtime"},
"runtimePlatform": {"@id": "schema:runtimePlatform"},
"rxcui": {"@id": "schema:rxcui"},
"safetyConsideration": {"@id": "schema:safetyConsideration"},
"salaryCurrency": {"@id": "schema:salaryCurrency"},
"salaryUponCompletion": {"@id": "schema:salaryUponCompletion"},
"sameAs": {"@id": "schema:sameAs", "@type": "@id"},
"sampleType": {"@id": "schema:sampleType"},
"saturatedFatContent": {"@id": "schema:saturatedFatContent"},
"scheduleTimezone": {"@id": "schema:scheduleTimezone"},
"scheduledPaymentDate": {"@id": "schema:scheduledPaymentDate", "@type": "Date"},
"scheduledTime": {"@id": "schema:scheduledTime", "@type": "DateTime"},
"schemaVersion": {"@id": "schema:schemaVersion"},
"schoolClosuresInfo": {"@id": "schema:schoolClosuresInfo", "@type": "@id"},
"screenCount": {"@id": "schema:screenCount"},
"screenshot": {"@id": "schema:screenshot", "@type": "@id"},
"sdDatePublished": {"@id": "schema:sdDatePublished", "@type": "Date"},
"sdLicense": {"@id": "schema:sdLicense", "@type": "@id"},
"sdPublisher": {"@id": "schema:sdPublisher"},
"season": {"@id": "schema:season", "@type": "@id"},
"seasonNumber": {"@id": "schema:seasonNumber"},
"seasons": {"@id": "schema:seasons"},
"seatNumber": {"@id": "schema:seatNumber"},
"seatRow": {"@id": "schema:seatRow"},
"seatSection": {"@id": "schema:seatSection"},
"seatingCapacity": {"@id": "schema:seatingCapacity"},
"seatingType": {"@id": "schema:seatingType"},
"secondaryPrevention": {"@id": "schema:secondaryPrevention"},
"securityClearanceRequirement": {"@id": "schema:securityClearanceRequirement"},
"securityScreening": {"@id": "schema:securityScreening"},
"seeks": {"@id": "schema:seeks"},
"seller": {"@id": "schema:seller"},
"sender": {"@id": "schema:sender"},
"sensoryRequirement": {"@id": "schema:sensoryRequirement"},
"sensoryUnit": {"@id": "schema:sensoryUnit"},
"serialNumber": {"@id": "schema:serialNumber"},
"seriousAdverseOutcome": {"@id": "schema:seriousAdverseOutcome"},
"serverStatus": {"@id": "schema:serverStatus"},
"servesCuisine": {"@id": "schema:servesCuisine"},
"serviceArea": {"@id": "schema:serviceArea"},
"serviceAudience": {"@id": "schema:serviceAudience"},
"serviceLocation": {"@id": "schema:serviceLocation"},
"serviceOperator": {"@id": "schema:serviceOperator"},
"serviceOutput": {"@id": "schema:serviceOutput"},
"servicePhone": {"@id": "schema:servicePhone"},
"servicePostalAddress": {"@id": "schema:servicePostalAddress"},
"serviceSmsNumber": {"@id": "schema:serviceSmsNumber"},
"serviceType": {"@id": "schema:serviceType"},
"serviceUrl": {"@id": "schema:serviceUrl", "@type": "@id"},
"servingSize": {"@id": "schema:servingSize"},
"sharedContent": {"@id": "schema:sharedContent"},
"sibling": {"@id": "schema:sibling"},
"siblings": {"@id": "schema:siblings"},
"signDetected": {"@id": "schema:signDetected"},
"signOrSymptom": {"@id": "schema:signOrSymptom"},
"significance": {"@id": "schema:significance"},
"significantLink": {"@id": "schema:significantLink", "@type": "@id"},
"significantLinks": {"@id": "schema:significantLinks", "@type": "@id"},
"skills": {"@id": "schema:skills"},
"sku": {"@id": "schema:sku"},
"slogan": {"@id": "schema:slogan"},
"smokingAllowed": {"@id": "schema:smokingAllowed"},
"sodiumContent": {"@id": "schema:sodiumContent"},
"softwareAddOn": {"@id": "schema:softwareAddOn"},
"softwareHelp": {"@id": "schema:softwareHelp"},
"softwareRequirements": {"@id": "schema:softwareRequirements"},
"softwareVersion": {"@id": "schema:softwareVersion"},
"sourceOrganization": {"@id": "schema:sourceOrganization"},
"sourcedFrom": {"@id": "schema:sourcedFrom"},
"spatial": {"@id": "schema:spatial"},
"spatialCoverage": {"@id": "schema:spatialCoverage"},
"speakable": {"@id": "schema:speakable", "@type": "@id"},
"specialCommitments": {"@id": "schema:specialCommitments"},
"specialOpeningHoursSpecification": {
"@id": "schema:specialOpeningHoursSpecification"
},
"specialty": {"@id": "schema:specialty"},
"speechToTextMarkup": {"@id": "schema:speechToTextMarkup"},
"speed": {"@id": "schema:speed"},
"spokenByCharacter": {"@id": "schema:spokenByCharacter"},
"sponsor": {"@id": "schema:sponsor"},
"sport": {"@id": "schema:sport"},
"sportsActivityLocation": {"@id": "schema:sportsActivityLocation"},
"sportsEvent": {"@id": "schema:sportsEvent"},
"sportsTeam": {"@id": "schema:sportsTeam"},
"spouse": {"@id": "schema:spouse"},
"stage": {"@id": "schema:stage"},
"stageAsNumber": {"@id": "schema:stageAsNumber"},
"starRating": {"@id": "schema:starRating"},
"startDate": {"@id": "schema:startDate", "@type": "Date"},
"startOffset": {"@id": "schema:startOffset"},
"startTime": {"@id": "schema:startTime", "@type": "DateTime"},
"status": {"@id": "schema:status"},
"steeringPosition": {"@id": "schema:steeringPosition"},
"step": {"@id": "schema:step"},
"stepValue": {"@id": "schema:stepValue"},
"steps": {"@id": "schema:steps"},
"storageRequirements": {"@id": "schema:storageRequirements"},
"streetAddress": {"@id": "schema:streetAddress"},
"strengthUnit": {"@id": "schema:strengthUnit"},
"strengthValue": {"@id": "schema:strengthValue"},
"structuralClass": {"@id": "schema:structuralClass"},
"study": {"@id": "schema:study"},
"studyDesign": {"@id": "schema:studyDesign"},
"studyLocation": {"@id": "schema:studyLocation"},
"studySubject": {"@id": "schema:studySubject"},
"stupidProperty": {"@id": "schema:stupidProperty"},
"subEvent": {"@id": "schema:subEvent"},
"subEvents": {"@id": "schema:subEvents"},
"subOrganization": {"@id": "schema:subOrganization"},
"subReservation": {"@id": "schema:subReservation"},
"subStageSuffix": {"@id": "schema:subStageSuffix"},
"subStructure": {"@id": "schema:subStructure"},
"subTest": {"@id": "schema:subTest"},
"subTrip": {"@id": "schema:subTrip"},
"subjectOf": {"@id": "schema:subjectOf"},
"subtitleLanguage": {"@id": "schema:subtitleLanguage"},
"successorOf": {"@id": "schema:successorOf"},
"sugarContent": {"@id": "schema:sugarContent"},
"suggestedAnswer": {"@id": "schema:suggestedAnswer"},
"suggestedGender": {"@id": "schema:suggestedGender"},
"suggestedMaxAge": {"@id": "schema:suggestedMaxAge"},
"suggestedMinAge": {"@id": "schema:suggestedMinAge"},
"suitableForDiet": {"@id": "schema:suitableForDiet"},
"superEvent": {"@id": "schema:superEvent"},
"supersededBy": {"@id": "schema:supersededBy"},
"supply": {"@id": "schema:supply"},
"supplyTo": {"@id": "schema:supplyTo"},
"supportingData": {"@id": "schema:supportingData"},
"surface": {"@id": "schema:surface"},
"target": {"@id": "schema:target"},
"targetCollection": {"@id": "schema:targetCollection"},
"targetDescription": {"@id": "schema:targetDescription"},
"targetName": {"@id": "schema:targetName"},
"targetPlatform": {"@id": "schema:targetPlatform"},
"targetPopulation": {"@id": "schema:targetPopulation"},
"targetProduct": {"@id": "schema:targetProduct"},
"targetUrl": {"@id": "schema:targetUrl", "@type": "@id"},
"taxID": {"@id": "schema:taxID"},
"telephone": {"@id": "schema:telephone"},
"temporal": {"@id": "schema:temporal"},
"temporalCoverage": {"@id": "schema:temporalCoverage"},
"termCode": {"@id": "schema:termCode"},
"termDuration": {"@id": "schema:termDuration"},
"termsOfService": {"@id": "schema:termsOfService"},
"termsPerYear": {"@id": "schema:termsPerYear"},
"text": {"@id": "schema:text"},
"textValue": {"@id": "schema:textValue"},
"thumbnail": {"@id": "schema:thumbnail"},
"thumbnailUrl": {"@id": "schema:thumbnailUrl", "@type": "@id"},
"tickerSymbol": {"@id": "schema:tickerSymbol"},
"ticketNumber": {"@id": "schema:ticketNumber"},
"ticketToken": {"@id": "schema:ticketToken"},
"ticketedSeat": {"@id": "schema:ticketedSeat"},
"timeOfDay": {"@id": "schema:timeOfDay"},
"timeRequired": {"@id": "schema:timeRequired"},
"timeToComplete": {"@id": "schema:timeToComplete"},
"tissueSample": {"@id": "schema:tissueSample"},
"title": {"@id": "schema:title"},
"toLocation": {"@id": "schema:toLocation"},
"toRecipient": {"@id": "schema:toRecipient"},
"tongueWeight": {"@id": "schema:tongueWeight"},
"tool": {"@id": "schema:tool"},
"torque": {"@id": "schema:torque"},
"totalJobOpenings": {"@id": "schema:totalJobOpenings"},
"totalPaymentDue": {"@id": "schema:totalPaymentDue"},
"totalPrice": {"@id": "schema:totalPrice"},
"totalTime": {"@id": "schema:totalTime"},
"tourBookingPage": {"@id": "schema:tourBookingPage", "@type": "@id"},
"touristType": {"@id": "schema:touristType"},
"track": {"@id": "schema:track"},
"trackingNumber": {"@id": "schema:trackingNumber"},
"trackingUrl": {"@id": "schema:trackingUrl", "@type": "@id"},
"tracks": {"@id": "schema:tracks"},
"trailer": {"@id": "schema:trailer"},
"trailerWeight": {"@id": "schema:trailerWeight"},
"trainName": {"@id": "schema:trainName"},
"trainNumber": {"@id": "schema:trainNumber"},
"trainingSalary": {"@id": "schema:trainingSalary"},
"transFatContent": {"@id": "schema:transFatContent"},
"transcript": {"@id": "schema:transcript"},
"translationOfWork": {"@id": "schema:translationOfWork"},
"translator": {"@id": "schema:translator"},
"transmissionMethod": {"@id": "schema:transmissionMethod"},
"travelBans": {"@id": "schema:travelBans", "@type": "@id"},
"trialDesign": {"@id": "schema:trialDesign"},
"tributary": {"@id": "schema:tributary"},
"typeOfBed": {"@id": "schema:typeOfBed"},
"typeOfGood": {"@id": "schema:typeOfGood"},
"typicalAgeRange": {"@id": "schema:typicalAgeRange"},
"typicalCreditsPerTerm": {"@id": "schema:typicalCreditsPerTerm"},
"typicalTest": {"@id": "schema:typicalTest"},
"underName": {"@id": "schema:underName"},
"unitCode": {"@id": "schema:unitCode"},
"unitText": {"@id": "schema:unitText"},
"unnamedSourcesPolicy": {"@id": "schema:unnamedSourcesPolicy", "@type": "@id"},
"unsaturatedFatContent": {"@id": "schema:unsaturatedFatContent"},
"uploadDate": {"@id": "schema:uploadDate", "@type": "Date"},
"upvoteCount": {"@id": "schema:upvoteCount"},
"url": {"@id": "schema:url", "@type": "@id"},
"urlTemplate": {"@id": "schema:urlTemplate"},
"usageInfo": {"@id": "schema:usageInfo", "@type": "@id"},
"usedToDiagnose": {"@id": "schema:usedToDiagnose"},
"userInteractionCount": {"@id": "schema:userInteractionCount"},
"usesDevice": {"@id": "schema:usesDevice"},
"usesHealthPlanIdStandard": {"@id": "schema:usesHealthPlanIdStandard"},
"validFor": {"@id": "schema:validFor"},
"validFrom": {"@id": "schema:validFrom", "@type": "Date"},
"validIn": {"@id": "schema:validIn"},
"validThrough": {"@id": "schema:validThrough", "@type": "Date"},
"validUntil": {"@id": "schema:validUntil", "@type": "Date"},
"value": {"@id": "schema:value"},
"valueAddedTaxIncluded": {"@id": "schema:valueAddedTaxIncluded"},
"valueMaxLength": {"@id": "schema:valueMaxLength"},
"valueMinLength": {"@id": "schema:valueMinLength"},
"valueName": {"@id": "schema:valueName"},
"valuePattern": {"@id": "schema:valuePattern"},
"valueReference": {"@id": "schema:valueReference"},
"valueRequired": {"@id": "schema:valueRequired"},
"variableMeasured": {"@id": "schema:variableMeasured"},
"variablesMeasured": {"@id": "schema:variablesMeasured"},
"variantCover": {"@id": "schema:variantCover"},
"vatID": {"@id": "schema:vatID"},
"vehicleConfiguration": {"@id": "schema:vehicleConfiguration"},
"vehicleEngine": {"@id": "schema:vehicleEngine"},
"vehicleIdentificationNumber": {"@id": "schema:vehicleIdentificationNumber"},
"vehicleInteriorColor": {"@id": "schema:vehicleInteriorColor"},
"vehicleInteriorType": {"@id": "schema:vehicleInteriorType"},
"vehicleModelDate": {"@id": "schema:vehicleModelDate", "@type": "Date"},
"vehicleSeatingCapacity": {"@id": "schema:vehicleSeatingCapacity"},
"vehicleSpecialUsage": {"@id": "schema:vehicleSpecialUsage"},
"vehicleTransmission": {"@id": "schema:vehicleTransmission"},
"vendor": {"@id": "schema:vendor"},
"verificationFactCheckingPolicy": {
"@id": "schema:verificationFactCheckingPolicy",
"@type": "@id",
},
"version": {"@id": "schema:version"},
"video": {"@id": "schema:video"},
"videoFormat": {"@id": "schema:videoFormat"},
"videoFrameSize": {"@id": "schema:videoFrameSize"},
"videoQuality": {"@id": "schema:videoQuality"},
"volumeNumber": {"@id": "schema:volumeNumber"},
"warning": {"@id": "schema:warning"},
"warranty": {"@id": "schema:warranty"},
"warrantyPromise": {"@id": "schema:warrantyPromise"},
"warrantyScope": {"@id": "schema:warrantyScope"},
"webCheckinTime": {"@id": "schema:webCheckinTime", "@type": "DateTime"},
"webFeed": {"@id": "schema:webFeed", "@type": "@id"},
"weight": {"@id": "schema:weight"},
"weightTotal": {"@id": "schema:weightTotal"},
"wheelbase": {"@id": "schema:wheelbase"},
"width": {"@id": "schema:width"},
"winner": {"@id": "schema:winner"},
"wordCount": {"@id": "schema:wordCount"},
"workExample": {"@id": "schema:workExample"},
"workFeatured": {"@id": "schema:workFeatured"},
"workHours": {"@id": "schema:workHours"},
"workLocation": {"@id": "schema:workLocation"},
"workPerformed": {"@id": "schema:workPerformed"},
"workPresented": {"@id": "schema:workPresented"},
"workTranslation": {"@id": "schema:workTranslation"},
"workload": {"@id": "schema:workload"},
"worksFor": {"@id": "schema:worksFor"},
"worstRating": {"@id": "schema:worstRating"},
"xpath": {"@id": "schema:xpath"},
"yearBuilt": {"@id": "schema:yearBuilt"},
"yearlyRevenue": {"@id": "schema:yearlyRevenue"},
"yearsInOperation": {"@id": "schema:yearsInOperation"},
"yield": {"@id": "schema:yield"},
"http://publications.europa.eu/mdr/eli/index.html": {
"@id": "http://publications.europa.eu/mdr/eli/index.html"
},
"httpMethod": {"@id": "schema:httpMethod"},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#Automotive_Ontology_Working_Group": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#Automotive_Ontology_Working_Group"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#FIBO": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#FIBO"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#GLEIF": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#GLEIF"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#IIT-CNR.it": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#IIT-CNR.it"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#MBZ": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#MBZ"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#Tourism": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#Tourism"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_ActionCollabClass": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_ActionCollabClass"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_DatasetClass": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_DatasetClass"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_GoodRelationsClass": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_GoodRelationsClass"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_GoodRelationsTerms": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_GoodRelationsTerms"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_LRMIClass": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_LRMIClass"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_QAStackExchange": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_QAStackExchange"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_WikiDoc": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_WikiDoc"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_bibex": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_bibex"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_rNews": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_rNews"
},
"https://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#STI_Accommodation_Ontology": {
"@id": "https://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#STI_Accommodation_Ontology"
},
"https://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#TP": {
"@id": "https://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#TP"
},
"https://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#TP-draws": {
"@id": "https://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#TP-draws"
},
}
CONTEXT["sc"] = "http://schema.org#"
import logging
import mimetypes
import os
import re
import urllib.parse
import uuid
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Q
from django.urls import reverse
from django.utils import timezone
from rest_framework import serializers
from funkwhale_api.common import utils as funkwhale_utils
from funkwhale_api.common import models as common_models
from funkwhale_api.common import utils as common_utils
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.federation import activity, actors, contexts, jsonld, models, utils
from funkwhale_api.history import models as history_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.moderation import serializers as moderation_serializers
from funkwhale_api.moderation import signals as moderation_signals
from funkwhale_api.music import licenses
from funkwhale_api.music import models as music_models
from funkwhale_api.music import tasks as music_tasks
from funkwhale_api.playlists import models as playlists_models
from funkwhale_api.tags import models as tags_models
from . import activity, actors, contexts, jsonld, models, tasks, utils
logger = logging.getLogger(__name__)
class LinkSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Link])
href = serializers.URLField(max_length=500)
mediaType = serializers.CharField()
def include_if_not_none(data, value, field):
if value is not None:
data[field] = value
class MultipleSerializer(serializers.Serializer):
"""
A serializer that will try multiple serializers in turn
"""
def __init__(self, *args, **kwargs):
self.allowed = kwargs.pop("allowed")
super().__init__(*args, **kwargs)
def to_internal_value(self, v):
last_exception = None
for serializer_class in self.allowed:
s = serializer_class(data=v)
try:
s.is_valid(raise_exception=True)
except serializers.ValidationError as e:
last_exception = e
else:
return s.validated_data
raise last_exception
class TruncatedCharField(serializers.CharField):
def __init__(self, *args, **kwargs):
self.truncate_length = kwargs.pop("truncate_length")
super().__init__(*args, **kwargs)
def to_internal_value(self, v):
v = super().to_internal_value(v)
if v:
v = v[: self.truncate_length]
return v
class TagSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Hashtag])
name = serializers.CharField(max_length=100)
class Meta:
jsonld_mapping = {
"href": jsonld.first_id(contexts.AS.href),
"mediaType": jsonld.first_val(contexts.AS.mediaType),
}
jsonld_mapping = {"name": jsonld.first_val(contexts.AS.name)}
def validate_name(self, value):
if value.startswith("#"):
# remove trailing #
value = value[1:]
return value
def tag_list(tagged_items):
return [
repr_tag(item.tag.name)
for item in sorted(set(tagged_items.all()), key=lambda i: i.tag.name)
]
def is_mimetype(mt, allowed_mimetypes):
for allowed in allowed_mimetypes:
if allowed.endswith("/*"):
if mt.startswith(allowed.replace("*", "")):
return True
else:
if mt == allowed:
return True
return False
class MediaSerializer(jsonld.JsonLdSerializer):
mediaType = serializers.CharField()
def __init__(self, *args, **kwargs):
self.allowed_mimetypes = kwargs.pop("allowed_mimetypes", [])
self.allow_empty_mimetype = kwargs.pop("allow_empty_mimetype", False)
super().__init__(*args, **kwargs)
self.fields["mediaType"].required = not self.allow_empty_mimetype
self.fields["mediaType"].allow_null = self.allow_empty_mimetype
def validate_mediaType(self, v):
if not self.allowed_mimetypes:
# no restrictions
return v
for mt in self.allowed_mimetypes:
if mt.endswith("/*"):
if v.startswith(mt.replace("*", "")):
return v
else:
if v == mt:
if self.allow_empty_mimetype and not v:
return None
if not is_mimetype(v, self.allowed_mimetypes):
raise serializers.ValidationError(
f"Invalid mimetype {v}. Allowed: {self.allowed_mimetypes}"
)
return v
class LinkSerializer(MediaSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Link])
href = serializers.URLField(max_length=500)
bitrate = serializers.IntegerField(min_value=0, required=False)
size = serializers.IntegerField(min_value=0, required=False)
class Meta:
jsonld_mapping = {
"href": jsonld.first_id(contexts.AS.href),
"mediaType": jsonld.first_val(contexts.AS.mediaType),
"bitrate": jsonld.first_val(contexts.FW.bitrate),
"size": jsonld.first_val(contexts.FW.size),
}
class LinkListSerializer(serializers.ListField):
def __init__(self, *args, **kwargs):
kwargs.setdefault("child", LinkSerializer(jsonld_expand=False))
self.keep_mediatype = kwargs.pop("keep_mediatype", [])
super().__init__(*args, **kwargs)
def to_internal_value(self, v):
links = super().to_internal_value(v)
if not self.keep_mediatype:
# no further filtering required
return links
links = [
link
for link in links
if link.get("mediaType")
and is_mimetype(link["mediaType"], self.keep_mediatype)
]
if not self.allow_empty and len(links) == 0:
self.fail("empty")
return links
class ImageSerializer(MediaSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Image, contexts.AS.Link])
href = serializers.URLField(max_length=500, required=False)
url = serializers.URLField(max_length=500, required=False)
class Meta:
jsonld_mapping = {
"url": jsonld.first_id(contexts.AS.url),
"href": jsonld.first_id(contexts.AS.href),
"mediaType": jsonld.first_val(contexts.AS.mediaType),
}
def validate(self, data):
validated_data = super().validate(data)
if "url" not in validated_data:
try:
validated_data["url"] = validated_data["href"]
except KeyError:
if self.required:
raise serializers.ValidationError(
"Invalid mimetype {}. Allowed: {}".format(v, self.allowed_mimetypes)
"You need to provide a url or href"
)
return validated_data
class URLSerializer(jsonld.JsonLdSerializer):
href = serializers.URLField(max_length=500)
mediaType = serializers.CharField(required=False)
class Meta:
jsonld_mapping = {
"href": jsonld.first_id(contexts.AS.href, aliases=[jsonld.raw("@id")]),
"mediaType": jsonld.first_val(contexts.AS.mediaType),
}
class EndpointsSerializer(jsonld.JsonLdSerializer):
sharedInbox = serializers.URLField(max_length=500, required=False)
......@@ -66,23 +214,77 @@ class PublicKeySerializer(jsonld.JsonLdSerializer):
jsonld_mapping = {"publicKeyPem": jsonld.first_val(contexts.SEC.publicKeyPem)}
def get_by_media_type(urls, media_type):
for url in urls:
if url.get("mediaType", "text/html") == media_type:
return url
class BasicActorSerializer(jsonld.JsonLdSerializer):
id = serializers.URLField(max_length=500)
type = serializers.ChoiceField(
choices=[getattr(contexts.AS, c[0]) for c in models.TYPE_CHOICES]
)
class Meta:
jsonld_mapping = {}
class ActorSerializer(jsonld.JsonLdSerializer):
id = serializers.URLField(max_length=500)
outbox = serializers.URLField(max_length=500)
inbox = serializers.URLField(max_length=500)
outbox = serializers.URLField(max_length=500, required=False)
inbox = serializers.URLField(max_length=500, required=False)
url = serializers.ListField(
child=URLSerializer(jsonld_expand=False), required=False, min_length=0
)
type = serializers.ChoiceField(
choices=[getattr(contexts.AS, c[0]) for c in models.TYPE_CHOICES]
)
preferredUsername = serializers.CharField()
manuallyApprovesFollowers = serializers.NullBooleanField(required=False)
name = serializers.CharField(required=False, max_length=200)
summary = serializers.CharField(max_length=None, required=False)
followers = serializers.URLField(max_length=500)
manuallyApprovesFollowers = serializers.BooleanField(
required=False, allow_null=True
)
name = serializers.CharField(
required=False, max_length=200, allow_blank=True, allow_null=True
)
summary = TruncatedCharField(
truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH,
required=False,
allow_null=True,
allow_blank=True,
)
followers = serializers.URLField(max_length=500, required=False)
following = serializers.URLField(max_length=500, required=False, allow_null=True)
publicKey = PublicKeySerializer(required=False)
endpoints = EndpointsSerializer(required=False)
icon = ImageSerializer(
allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=True,
)
attributedTo = serializers.URLField(max_length=500, required=False)
tags = serializers.ListField(min_length=0, required=False, allow_null=True)
def validate_tags(self, tags):
valid_tags = []
for tag in tags:
s = TagSerializer(data=tag)
if s.is_valid():
valid_tags.append(s.validated_data)
return valid_tags
category = serializers.CharField(required=False)
# languages = serializers.Char(
# music_models.ARTIST_CONTENT_CATEGORY_CHOICES, required=False, default="music",
# )
class Meta:
# not strictly necessary because it's not a model serializer
# but used by tasks.py/fetch
model = models.Actor
jsonld_mapping = {
"outbox": jsonld.first_id(contexts.AS.outbox),
"inbox": jsonld.first_id(contexts.LDP.inbox),
......@@ -97,8 +299,21 @@ class ActorSerializer(jsonld.JsonLdSerializer):
),
"mediaType": jsonld.first_val(contexts.AS.mediaType),
"endpoints": jsonld.first_obj(contexts.AS.endpoints),
"icon": jsonld.first_obj(contexts.AS.icon),
"url": jsonld.raw(contexts.AS.url),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
"tags": jsonld.raw(contexts.AS.tag),
"category": jsonld.first_val(contexts.SC.category),
# "language": jsonld.first_val(contexts.SC.inLanguage),
}
def validate_category(self, v):
return (
v
if v in [t for t, _ in music_models.ARTIST_CONTENT_CATEGORY_CHOICES]
else None
)
def to_representation(self, instance):
ret = {
"id": instance.fid,
......@@ -113,50 +328,87 @@ class ActorSerializer(jsonld.JsonLdSerializer):
ret["followers"] = instance.followers_url
if instance.following_url:
ret["following"] = instance.following_url
if instance.summary:
ret["summary"] = instance.summary
if instance.manually_approves_followers is not None:
ret["manuallyApprovesFollowers"] = instance.manually_approves_followers
if instance.summary_obj_id:
ret["summary"] = instance.summary_obj.rendered
urls = []
if instance.url:
urls.append(
{"type": "Link", "href": instance.url, "mediaType": "text/html"}
)
channel = instance.get_channel()
if channel:
ret["url"] = [
{
"type": "Link",
"href": (
instance.channel.get_absolute_url()
if instance.channel.artist.is_local
else instance.get_absolute_url()
),
"mediaType": "text/html",
},
{
"type": "Link",
"href": instance.channel.get_rss_url(),
"mediaType": "application/rss+xml",
},
]
include_image(ret, channel.artist.attachment_cover, "icon")
if channel.artist.description_id:
ret["summary"] = channel.artist.description.rendered
ret["attributedTo"] = channel.attributed_to.fid
ret["category"] = channel.artist.content_category
ret["tag"] = tag_list(channel.artist.tagged_items.all())
else:
ret["url"] = [
{
"type": "Link",
"href": instance.get_absolute_url(),
"mediaType": "text/html",
}
]
include_image(ret, instance.attachment_icon, "icon")
ret["@context"] = jsonld.get_default_context()
if instance.public_key:
ret["publicKey"] = {
"owner": instance.fid,
"publicKeyPem": instance.public_key,
"id": "{}#main-key".format(instance.fid),
"id": f"{instance.fid}#main-key",
}
ret["endpoints"] = {}
if instance.shared_inbox_url:
ret["endpoints"]["sharedInbox"] = instance.shared_inbox_url
try:
if instance.user.avatar:
ret["icon"] = {
"type": "Image",
"mediaType": mimetypes.guess_type(instance.user.avatar_path)[0],
"url": utils.full_url(instance.user.avatar.crop["400x400"].url),
}
except ObjectDoesNotExist:
pass
return ret
def prepare_missing_fields(self):
kwargs = {
"fid": self.validated_data["id"],
"outbox_url": self.validated_data["outbox"],
"inbox_url": self.validated_data["inbox"],
"outbox_url": self.validated_data.get("outbox"),
"inbox_url": self.validated_data.get("inbox"),
"following_url": self.validated_data.get("following"),
"followers_url": self.validated_data.get("followers"),
"summary": self.validated_data.get("summary"),
"type": self.validated_data["type"],
"name": self.validated_data.get("name"),
"preferred_username": self.validated_data["preferredUsername"],
}
url = get_by_media_type(self.validated_data.get("url", []), "text/html")
if url:
kwargs["url"] = url["href"]
maf = self.validated_data.get("manuallyApprovesFollowers")
if maf is not None:
kwargs["manually_approves_followers"] = maf
domain = urllib.parse.urlparse(kwargs["fid"]).netloc
domain, domain_created = models.Domain.objects.get_or_create(pk=domain)
if domain_created and not domain.is_local:
from . import tasks
# first time we see the domain, we trigger nodeinfo fetching
tasks.update_domain_nodeinfo(domain_name=domain.name)
......@@ -181,18 +433,112 @@ class ActorSerializer(jsonld.JsonLdSerializer):
def save(self, **kwargs):
d = self.prepare_missing_fields()
d.update(kwargs)
return models.Actor.objects.update_or_create(fid=d["fid"], defaults=d)[0]
actor = models.Actor.objects.update_or_create(fid=d["fid"], defaults=d)[0]
common_utils.attach_content(
actor, "summary_obj", self.validated_data["summary"]
)
if "icon" in self.validated_data:
new_value = self.validated_data["icon"]
common_utils.attach_file(
actor,
"attachment_icon",
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None
),
)
rss_url = get_by_media_type(
self.validated_data.get("url", []), "application/rss+xml"
)
if rss_url:
rss_url = rss_url["href"]
attributed_to = self.validated_data.get("attributedTo", actor.fid)
if rss_url:
# if the actor is attributed to another actor, and there is a RSS url,
# then we consider it's a channel
create_or_update_channel(
actor,
rss_url=rss_url,
attributed_to_fid=attributed_to,
**self.validated_data,
)
return actor
def validate(self, data):
validated_data = super().validate(data)
if "summary" in data:
validated_data["summary"] = {
"content_type": "text/html",
"text": data["summary"],
}
else:
validated_data["summary"] = None
return validated_data
def validate_summary(self, value):
if value:
return value[:500]
def create_or_update_channel(actor, rss_url, attributed_to_fid, **validated_data):
from funkwhale_api.audio import models as audio_models
attributed_to = actors.get_actor(attributed_to_fid)
artist_defaults = {
"name": validated_data.get("name", validated_data["preferredUsername"]),
"fid": validated_data["id"],
"content_category": validated_data.get("category", "music") or "music",
"attributed_to": attributed_to,
}
artist, created = music_models.Artist.objects.update_or_create(
channel__attributed_to=attributed_to,
channel__actor=actor,
defaults=artist_defaults,
)
common_utils.attach_content(artist, "description", validated_data.get("summary"))
if "icon" in validated_data:
new_value = validated_data["icon"]
common_utils.attach_file(
artist,
"attachment_cover",
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None
),
)
tags = [t["name"] for t in validated_data.get("tags", []) or []]
tags_models.set_tags(artist, *tags)
if created:
uid = uuid.uuid4()
fid = utils.full_url(
reverse("federation:music:libraries-detail", kwargs={"uuid": uid})
)
library = attributed_to.libraries.create(
privacy_level="everyone",
name=artist_defaults["name"],
fid=fid,
uuid=uid,
)
else:
library = artist.channel.library
channel_defaults = {
"actor": actor,
"attributed_to": attributed_to,
"rss_url": rss_url,
"artist": artist,
"library": library,
}
channel, created = audio_models.Channel.objects.update_or_create(
actor=actor,
attributed_to=attributed_to,
defaults=channel_defaults,
)
return channel
class APIActorSerializer(serializers.ModelSerializer):
class Meta:
model = models.Actor
fields = [
"id",
"fid",
"url",
"creation_date",
......@@ -212,6 +558,7 @@ class BaseActivitySerializer(serializers.Serializer):
id = serializers.URLField(max_length=500, required=False)
type = serializers.CharField(max_length=100)
actor = serializers.URLField(max_length=500)
object = serializers.JSONField(required=False, allow_null=True)
def validate_actor(self, v):
expected = self.context.get("actor")
......@@ -234,17 +581,30 @@ class BaseActivitySerializer(serializers.Serializer):
)
def validate(self, data):
data["recipients"] = self.validate_recipients(self.initial_data)
self.validate_recipients(data, self.initial_data)
return super().validate(data)
def validate_recipients(self, payload):
def validate_recipients(self, data, payload):
"""
Ensure we have at least a to/cc field with valid actors
"""
to = payload.get("to", [])
cc = payload.get("cc", [])
data["to"] = payload.get("to", [])
data["cc"] = payload.get("cc", [])
if (
not data["to"]
and data.get("type") in ["Follow", "Accept"]
and data.get("object")
):
# there isn't always a to field for Accept/Follow
# in their follow activity, so we consider the recipient
# to be the follow object
if data["type"] == "Follow":
data["to"].append(str(data.get("object")))
else:
data["to"].append(data.get("object", {}).get("actor"))
if not to and not cc:
if not data["to"] and not data["cc"] and not self.context.get("recipients"):
raise serializers.ValidationError(
"We cannot handle an activity with no recipient"
)
......@@ -294,7 +654,6 @@ class FollowSerializer(serializers.Serializer):
def save(self, **kwargs):
target = self.validated_data["object"]
if target._meta.label == "music.Library":
follow_class = models.LibraryFollow
else:
......@@ -352,11 +711,10 @@ class APIFollowSerializer(serializers.ModelSerializer):
]
class AcceptFollowSerializer(serializers.Serializer):
class FollowActionSerializer(serializers.Serializer):
id = serializers.URLField(max_length=500, required=False)
actor = serializers.URLField(max_length=500)
object = FollowSerializer()
type = serializers.ChoiceField(choices=["Accept"])
def validate_actor(self, v):
expected = self.context.get("actor")
......@@ -384,12 +742,11 @@ class AcceptFollowSerializer(serializers.Serializer):
follow_class.objects.filter(
target=target, actor=validated_data["object"]["actor"]
)
.exclude(approved=True)
.select_related()
.get()
)
except follow_class.DoesNotExist:
raise serializers.ValidationError("No follow to accept")
raise serializers.ValidationError(f"No follow to {self.action_type}")
return validated_data
def to_representation(self, instance):
......@@ -400,12 +757,17 @@ class AcceptFollowSerializer(serializers.Serializer):
return {
"@context": jsonld.get_default_context(),
"id": instance.get_federation_id() + "/accept",
"type": "Accept",
"id": instance.get_federation_id() + f"/{self.action_type}",
"type": self.action_type.title(),
"actor": actor.fid,
"object": FollowSerializer(instance).data,
}
class AcceptFollowSerializer(FollowActionSerializer):
type = serializers.ChoiceField(choices=["Accept"])
action_type = "accept"
def save(self):
follow = self.validated_data["follow"]
follow.approved = True
......@@ -415,6 +777,17 @@ class AcceptFollowSerializer(serializers.Serializer):
return follow
class RejectFollowSerializer(FollowActionSerializer):
type = serializers.ChoiceField(choices=["Reject"])
action_type = "reject"
def save(self):
follow = self.validated_data["follow"]
follow.approved = False
follow.save()
return follow
class UndoFollowSerializer(serializers.Serializer):
id = serializers.URLField(max_length=500)
actor = serializers.URLField(max_length=500)
......@@ -448,7 +821,9 @@ class UndoFollowSerializer(serializers.Serializer):
actor=validated_data["actor"], target=target
).get()
except follow_class.DoesNotExist:
raise serializers.ValidationError("No follow to remove")
raise serializers.ValidationError(
f"No follow to remove follow_class = {follow_class}"
)
return validated_data
def to_representation(self, instance):
......@@ -488,7 +863,7 @@ class ActorWebfingerSerializer(serializers.Serializer):
def to_representation(self, instance):
data = {}
data["subject"] = "acct:{}".format(instance.webfinger_subject)
data["subject"] = f"acct:{instance.webfinger_subject}"
data["links"] = [
{"rel": "self", "href": instance.fid, "type": "application/activity+json"}
]
......@@ -514,8 +889,7 @@ class ActivitySerializer(serializers.Serializer):
try:
object_serializer = OBJECT_SERIALIZERS[type]
except KeyError:
raise serializers.ValidationError("Unsupported type {}".format(type))
raise serializers.ValidationError(f"Unsupported type {type}")
serializer = object_serializer(data=value)
serializer.is_valid(raise_exception=True)
return serializer.data
......@@ -566,10 +940,13 @@ OBJECT_SERIALIZERS = {t: ObjectSerializer for t in activity.OBJECT_TYPES}
def get_additional_fields(data):
UNSET = object()
additional_fields = {}
for field in ["name", "summary"]:
for field in ["name", "summary", "library", "audience", "published"]:
v = data.get(field, UNSET)
if v == UNSET:
continue
# in some cases we use the serializer context to pass objects instances, we don't want to add them
if not isinstance(v, str) or isinstance(v, dict):
continue
additional_fields[field] = v
return additional_fields
......@@ -577,8 +954,6 @@ def get_additional_fields(data):
PAGINATED_COLLECTION_JSONLD_MAPPING = {
"totalItems": jsonld.first_val(contexts.AS.totalItems),
"actor": jsonld.first_id(contexts.AS.actor),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
"first": jsonld.first_id(contexts.AS.first),
"last": jsonld.first_id(contexts.AS.last),
"partOf": jsonld.first_id(contexts.AS.partOf),
......@@ -586,10 +961,10 @@ PAGINATED_COLLECTION_JSONLD_MAPPING = {
class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Collection])
type = serializers.ChoiceField(
choices=[contexts.AS.Collection, contexts.AS.OrderedCollection]
)
totalItems = serializers.IntegerField(min_value=0)
actor = serializers.URLField(max_length=500, required=False)
attributedTo = serializers.URLField(max_length=500, required=False)
id = serializers.URLField(max_length=500)
first = serializers.URLField(max_length=500)
last = serializers.URLField(max_length=500)
......@@ -597,27 +972,13 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
class Meta:
jsonld_mapping = PAGINATED_COLLECTION_JSONLD_MAPPING
def validate(self, validated_data):
d = super().validate(validated_data)
actor = d.get("actor")
attributed_to = d.get("attributedTo")
if not actor and not attributed_to:
raise serializers.ValidationError(
"You need to provide at least actor or attributedTo"
)
d["attributedTo"] = attributed_to or actor
return d
def to_representation(self, conf):
paginator = Paginator(conf["items"], conf.get("page_size", 20))
first = funkwhale_utils.set_query_parameter(conf["id"], page=1)
first = common_utils.set_query_parameter(conf["id"], page=1)
current = first
last = funkwhale_utils.set_query_parameter(conf["id"], page=paginator.num_pages)
d = {
last = common_utils.set_query_parameter(conf["id"], page=paginator.num_pages)
data = {
"id": conf["id"],
# XXX Stable release: remove the obsolete actor field
"actor": conf["actor"].fid,
"attributedTo": conf["actor"].fid,
"totalItems": paginator.count,
"type": conf.get("type", "Collection"),
......@@ -625,19 +986,19 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
"first": first,
"last": last,
}
d.update(get_additional_fields(conf))
data.update(get_additional_fields(conf))
if self.context.get("include_ap_context", True):
d["@context"] = jsonld.get_default_context()
return d
data["@context"] = jsonld.get_default_context()
return data
class LibrarySerializer(PaginatedCollectionSerializer):
type = serializers.ChoiceField(
choices=[contexts.AS.Collection, contexts.FW.Library]
)
actor = serializers.URLField(max_length=500, required=False)
attributedTo = serializers.URLField(max_length=500, required=False)
name = serializers.CharField()
summary = serializers.CharField(allow_blank=True, allow_null=True, required=False)
followers = serializers.URLField(max_length=500)
audience = serializers.ChoiceField(
choices=["", "./", None, "https://www.w3.org/ns/activitystreams#Public"],
required=False,
......@@ -646,36 +1007,56 @@ class LibrarySerializer(PaginatedCollectionSerializer):
)
class Meta:
jsonld_mapping = funkwhale_utils.concat_dicts(
# not strictly necessary because it's not a model serializer
# but used by tasks.py/fetch
model = music_models.Library
jsonld_mapping = common_utils.concat_dicts(
PAGINATED_COLLECTION_JSONLD_MAPPING,
{
"name": jsonld.first_val(contexts.AS.name),
"summary": jsonld.first_val(contexts.AS.summary),
"audience": jsonld.first_id(contexts.AS.audience),
"followers": jsonld.first_id(contexts.AS.followers),
"actor": jsonld.first_id(contexts.AS.actor),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
},
)
def validate(self, validated_data):
d = super().validate(validated_data)
actor = d.get("actor")
attributed_to = d.get("attributedTo")
if not actor and not attributed_to:
raise serializers.ValidationError(
"You need to provide at least actor or attributedTo"
)
d["attributedTo"] = attributed_to or actor
return d
def to_representation(self, library):
conf = {
"id": library.fid,
"name": library.name,
"summary": library.description,
"page_size": 100,
# XXX Stable release: remove the obsolete actor field
"actor": library.actor,
"attributedTo": library.actor,
"items": library.uploads.for_federation(),
"actor": library.actor,
"items": (
library.uploads.for_federation()
if not library.playlist_uploads.all()
else library.playlist_uploads.for_federation()
),
"type": "Library",
}
r = super().to_representation(conf)
r["audience"] = (
contexts.AS.Public if library.privacy_level == "everyone" else ""
)
r["followers"] = library.followers_url
return r
def create(self, validated_data):
if self.instance:
actor = self.instance.actor
else:
actor = utils.retrieve_ap_object(
validated_data["attributedTo"],
actor=self.context.get("fetch_actor"),
......@@ -689,13 +1070,14 @@ class LibrarySerializer(PaginatedCollectionSerializer):
defaults={
"uploads_count": validated_data["totalItems"],
"name": validated_data["name"],
"description": validated_data.get("summary"),
"followers_url": validated_data["followers"],
"privacy_level": privacy[validated_data["audience"]],
},
)
return library
def update(self, instance, validated_data):
return self.create(validated_data)
class CollectionPageSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.CollectionPage])
......@@ -740,36 +1122,40 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer):
def to_representation(self, conf):
page = conf["page"]
first = funkwhale_utils.set_query_parameter(conf["id"], page=1)
last = funkwhale_utils.set_query_parameter(
first = common_utils.set_query_parameter(conf["id"], page=1)
last = common_utils.set_query_parameter(
conf["id"], page=page.paginator.num_pages
)
id = funkwhale_utils.set_query_parameter(conf["id"], page=page.number)
id = common_utils.set_query_parameter(conf["id"], page=page.number)
d = {
"id": id,
"partOf": conf["id"],
# XXX Stable release: remove the obsolete actor field
"actor": conf["actor"].fid,
"attributedTo": conf["actor"].fid,
"totalItems": page.paginator.count,
"type": "CollectionPage",
"first": first,
"last": last,
"items": [
conf["item_serializer"](
i, context={"actor": conf["actor"], "include_ap_context": False}
i,
context={
"actor": conf["actor"],
"library": conf.get("library", None),
"include_ap_context": False,
},
).data
for i in page.object_list
],
}
if conf["actor"]:
d["attributedTo"] = conf["actor"].fid
if page.has_previous():
d["prev"] = funkwhale_utils.set_query_parameter(
d["prev"] = common_utils.set_query_parameter(
conf["id"], page=page.previous_page_number()
)
if page.has_next():
d["next"] = funkwhale_utils.set_query_parameter(
d["next"] = common_utils.set_query_parameter(
conf["id"], page=page.next_page_number()
)
d.update(get_additional_fields(conf))
......@@ -784,21 +1170,34 @@ MUSIC_ENTITY_JSONLD_MAPPING = {
"musicbrainzId": jsonld.first_val(contexts.FW.musicbrainzId),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
"tags": jsonld.raw(contexts.AS.tag),
"mediaType": jsonld.first_val(contexts.AS.mediaType),
"content": jsonld.first_val(contexts.AS.content),
}
class TagSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Hashtag])
name = serializers.CharField(max_length=100)
def repr_tag(tag_name):
return {"type": "Hashtag", "name": f"#{tag_name}"}
class Meta:
jsonld_mapping = {"name": jsonld.first_val(contexts.AS.name)}
def validate_name(self, value):
if value.startswith("#"):
# remove trailing #
value = value[1:]
return value
def include_content(repr, content_obj):
if not content_obj:
return
repr["content"] = common_utils.render_html(
content_obj.text, content_obj.content_type
)
repr["mediaType"] = "text/html"
def include_image(repr, attachment, field="image"):
if attachment:
repr[field] = {
"type": "Image",
"url": attachment.download_url_original,
"mediaType": attachment.mimetype or "image/jpeg",
}
else:
repr[field] = None
class MusicEntitySerializer(jsonld.JsonLdSerializer):
......@@ -811,44 +1210,111 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
tags = serializers.ListField(
child=TagSerializer(), min_length=0, required=False, allow_null=True
)
mediaType = serializers.ChoiceField(
choices=common_models.CONTENT_TEXT_SUPPORTED_TYPES,
default="text/html",
required=False,
)
content = TruncatedCharField(
truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH,
required=False,
allow_null=True,
)
@transaction.atomic
def update(self, instance, validated_data):
return self.update_or_create(validated_data)
@transaction.atomic
def update_or_create(self, validated_data):
instance = self.instance or self.Meta.model(fid=validated_data["id"])
creating = instance.pk is None
attributed_to_fid = validated_data.get("attributedTo")
if attributed_to_fid:
validated_data["attributedTo"] = actors.get_actor(attributed_to_fid)
updated_fields = funkwhale_utils.get_updated_fields(
updated_fields = common_utils.get_updated_fields(
self.updateable_fields, validated_data, instance
)
updated_fields = self.validate_updated_data(instance, updated_fields)
if updated_fields:
music_tasks.update_library_entity(instance, updated_fields)
set_ac = False
if "artist_credit" in updated_fields:
artist_credit = updated_fields.pop("artist_credit")
set_ac = True
if creating:
instance, created = self.Meta.model.objects.get_or_create(
fid=validated_data["id"], defaults=updated_fields
)
if set_ac:
instance.artist_credit.set(artist_credit)
else:
obj = music_tasks.update_library_entity(instance, updated_fields)
if set_ac:
obj.artist_credit.set(artist_credit)
tags = [t["name"] for t in validated_data.get("tags", []) or []]
tags_models.set_tags(instance, *tags)
common_utils.attach_content(
instance, "description", validated_data.get("description")
)
return instance
def get_tags_repr(self, instance):
return [
{"type": "Hashtag", "name": "#{}".format(item.tag.name)}
for item in sorted(instance.tagged_items.all(), key=lambda i: i.tag.name)
]
return tag_list(instance.tagged_items.all())
def validate_updated_data(self, instance, validated_data):
try:
attachment_cover = validated_data.pop("attachment_cover")
except KeyError:
return validated_data
if (
instance.attachment_cover
and instance.attachment_cover.url == attachment_cover["url"]
):
# we already have the proper attachment
return validated_data
# create the attachment by hand so it can be attached as the cover
validated_data["attachment_cover"] = common_models.Attachment.objects.create(
mimetype=attachment_cover.get("mediaType"),
url=attachment_cover["url"],
actor=instance.attributed_to,
)
return validated_data
def validate(self, data):
validated_data = super().validate(data)
if data.get("content"):
validated_data["description"] = {
"content_type": data["mediaType"],
"text": data["content"],
}
return validated_data
class ArtistSerializer(MusicEntitySerializer):
image = ImageSerializer(
allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=True,
)
updateable_fields = [
("name", "name"),
("musicbrainzId", "mbid"),
("attributedTo", "attributed_to"),
("image", "attachment_cover"),
]
class Meta:
model = music_models.Artist
jsonld_mapping = MUSIC_ENTITY_JSONLD_MAPPING
jsonld_mapping = common_utils.concat_dicts(
MUSIC_ENTITY_JSONLD_MAPPING,
{
"released": jsonld.first_val(contexts.FW.released),
"image": jsonld.first_obj(contexts.AS.image),
},
)
def to_representation(self, instance):
d = {
......@@ -857,100 +1323,161 @@ class ArtistSerializer(MusicEntitySerializer):
"name": instance.name,
"published": instance.creation_date.isoformat(),
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
"attributedTo": instance.attributed_to.fid
if instance.attributed_to
else None,
"attributedTo": (
instance.attributed_to.fid if instance.attributed_to else None
),
"tag": self.get_tags_repr(instance),
}
include_content(d, instance.description)
include_image(d, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context()
return d
create = MusicEntitySerializer.update_or_create
class ArtistCreditSerializer(jsonld.JsonLdSerializer):
artist = ArtistSerializer()
joinphrase = serializers.CharField(
trim_whitespace=False, required=False, allow_null=True, allow_blank=True
)
credit = serializers.CharField(
trim_whitespace=False, required=False, allow_null=True, allow_blank=True
)
published = serializers.DateTimeField()
id = serializers.URLField(max_length=500)
updateable_fields = [
("credit", "credit"),
("artist", "artist"),
("joinphrase", "joinphrase"),
]
class Meta:
model = music_models.ArtistCredit
jsonld_mapping = {
"artist": jsonld.first_obj(contexts.FW.artist),
"credit": jsonld.first_val(contexts.FW.credit),
"index": jsonld.first_val(contexts.FW.index),
"joinphrase": jsonld.first_val(contexts.FW.joinphrase),
"published": jsonld.first_val(contexts.AS.published),
}
def to_representation(self, instance):
data = {
"type": "ArtistCredit",
"id": instance.fid,
"artist": ArtistSerializer(
instance.artist, context={"include_ap_context": False}
).data,
"joinphrase": instance.joinphrase,
"credit": instance.credit,
"index": instance.index,
"published": instance.creation_date.isoformat(),
}
if self.context.get("include_ap_context", self.parent is None):
data["@context"] = jsonld.get_default_context()
return data
class AlbumSerializer(MusicEntitySerializer):
released = serializers.DateField(allow_null=True, required=False)
artists = serializers.ListField(child=ArtistSerializer(), min_length=1)
cover = LinkSerializer(
allowed_mimetypes=["image/*"], allow_null=True, required=False
artist_credit = serializers.ListField(child=ArtistCreditSerializer(), min_length=1)
image = ImageSerializer(
allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=True,
)
updateable_fields = [
("name", "title"),
("image", "attachment_cover"),
("musicbrainzId", "mbid"),
("attributedTo", "attributed_to"),
("released", "release_date"),
("cover", "attachment_cover"),
("artist_credit", "artist_credit"),
]
class Meta:
model = music_models.Album
jsonld_mapping = funkwhale_utils.concat_dicts(
jsonld_mapping = common_utils.concat_dicts(
MUSIC_ENTITY_JSONLD_MAPPING,
{
"released": jsonld.first_val(contexts.FW.released),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"),
"cover": jsonld.first_obj(contexts.FW.cover),
"artist_credit": jsonld.first_attr(contexts.FW.artist_credit, "@list"),
"image": jsonld.first_obj(contexts.AS.image),
},
)
def to_representation(self, instance):
d = {
data = {
"type": "Album",
"id": instance.fid,
"name": instance.title,
"published": instance.creation_date.isoformat(),
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
"released": instance.release_date.isoformat()
if instance.release_date
else None,
"artists": [
ArtistSerializer(
instance.artist, context={"include_ap_context": False}
).data
],
"attributedTo": instance.attributed_to.fid
if instance.attributed_to
else None,
"released": (
instance.release_date.isoformat() if instance.release_date else None
),
"attributedTo": (
instance.attributed_to.fid if instance.attributed_to else None
),
"tag": self.get_tags_repr(instance),
}
data["artist_credit"] = ArtistCreditSerializer(
instance.artist_credit.all(),
context={"include_ap_context": False},
many=True,
).data
include_content(data, instance.description)
if instance.attachment_cover:
d["cover"] = {
"type": "Link",
"href": instance.attachment_cover.download_url_original,
"mediaType": instance.attachment_cover.mimetype or "image/jpeg",
}
if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context()
return d
include_image(data, instance.attachment_cover)
def validate_updated_data(self, instance, validated_data):
try:
attachment_cover = validated_data.pop("attachment_cover")
except KeyError:
return validated_data
if self.context.get("include_ap_context", self.parent is None):
data["@context"] = jsonld.get_default_context()
return data
if (
instance.attachment_cover
and instance.attachment_cover.url == attachment_cover["href"]
):
# we already have the proper attachment
return validated_data
# create the attachment by hand so it can be attached as the album cover
validated_data["attachment_cover"] = common_models.Attachment.objects.create(
mimetype=attachment_cover["mediaType"],
url=attachment_cover["href"],
actor=instance.attributed_to,
def validate(self, data):
validated_data = super().validate(data)
if not self.parent:
artist_credit_data = validated_data["artist_credit"]
if artist_credit_data[0]["artist"].get("type", "Artist") == "Artist":
acs = []
for ac in validated_data["artist_credit"]:
acs.append(
utils.retrieve_ap_object(
ac["id"],
actor=self.context.get("fetch_actor"),
queryset=music_models.ArtistCredit,
serializer_class=ArtistCreditSerializer,
)
)
validated_data["artist_credit"] = acs
else:
# we have an actor as an artist, so it's a channel
actor = actors.get_actor(artist_credit_data[0]["artist"]["id"])
validated_data["artist_credit"] = [{"artist": actor.channel.artist}]
return validated_data
create = MusicEntitySerializer.update_or_create
class TrackSerializer(MusicEntitySerializer):
position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
disc = serializers.IntegerField(min_value=1, allow_null=True, required=False)
artists = serializers.ListField(child=ArtistSerializer(), min_length=1)
artist_credit = serializers.ListField(child=ArtistCreditSerializer(), min_length=1)
album = AlbumSerializer()
license = serializers.URLField(allow_null=True, required=False)
copyright = serializers.CharField(allow_null=True, required=False)
image = ImageSerializer(
allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=True,
)
updateable_fields = [
("name", "title"),
......@@ -960,24 +1487,26 @@ class TrackSerializer(MusicEntitySerializer):
("position", "position"),
("copyright", "copyright"),
("license", "license"),
("image", "attachment_cover"),
]
class Meta:
model = music_models.Track
jsonld_mapping = funkwhale_utils.concat_dicts(
jsonld_mapping = common_utils.concat_dicts(
MUSIC_ENTITY_JSONLD_MAPPING,
{
"album": jsonld.first_obj(contexts.FW.album),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"),
"artist_credit": jsonld.first_attr(contexts.FW.artist_credit, "@list"),
"copyright": jsonld.first_val(contexts.FW.copyright),
"disc": jsonld.first_val(contexts.FW.disc),
"license": jsonld.first_id(contexts.FW.license),
"position": jsonld.first_val(contexts.FW.position),
"image": jsonld.first_obj(contexts.AS.image),
},
)
def to_representation(self, instance):
d = {
data = {
"type": "Track",
"id": instance.fid,
"name": instance.title,
......@@ -985,57 +1514,62 @@ class TrackSerializer(MusicEntitySerializer):
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
"position": instance.position,
"disc": instance.disc_number,
"license": instance.local_license["identifiers"][0]
"license": (
instance.local_license["identifiers"][0]
if instance.local_license
else None,
else None
),
"copyright": instance.copyright if instance.copyright else None,
"artists": [
ArtistSerializer(
instance.artist, context={"include_ap_context": False}
).data
],
"artist_credit": ArtistCreditSerializer(
instance.artist_credit.all(),
context={"include_ap_context": False},
many=True,
).data,
"album": AlbumSerializer(
instance.album, context={"include_ap_context": False}
).data,
"attributedTo": instance.attributed_to.fid
if instance.attributed_to
else None,
"attributedTo": (
instance.attributed_to.fid if instance.attributed_to else None
),
"tag": self.get_tags_repr(instance),
}
include_content(data, instance.description)
include_image(data, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context()
return d
data["@context"] = jsonld.get_default_context()
return data
@transaction.atomic
def create(self, validated_data):
from funkwhale_api.music import tasks as music_tasks
references = {}
actors_to_fetch = set()
actors_to_fetch.add(
funkwhale_utils.recursive_getattr(
common_utils.recursive_getattr(
validated_data, "attributedTo", permissive=True
)
)
actors_to_fetch.add(
funkwhale_utils.recursive_getattr(
common_utils.recursive_getattr(
validated_data, "album.attributedTo", permissive=True
)
)
artists = (
funkwhale_utils.recursive_getattr(
validated_data, "artists", permissive=True
artist_credit = (
common_utils.recursive_getattr(
validated_data, "artist_credit", permissive=True
)
or []
)
album_artists = (
funkwhale_utils.recursive_getattr(
validated_data, "album.artists", permissive=True
album_artists_credit = (
common_utils.recursive_getattr(
validated_data, "album.artist_credit", permissive=True
)
or []
)
for artist in artists + album_artists:
actors_to_fetch.add(artist.get("attributedTo"))
for ac in artist_credit + album_artists_credit:
actors_to_fetch.add(ac["artist"].get("attributedTo"))
for url in actors_to_fetch:
if not url:
......@@ -1048,8 +1582,9 @@ class TrackSerializer(MusicEntitySerializer):
from_activity = self.context.get("activity")
if from_activity:
metadata["from_activity_id"] = from_activity.pk
track = music_tasks.get_track_from_import_metadata(metadata, update_cover=True)
track = music_tasks.get_track_from_import_metadata(
metadata, update_cover=True, query_mb=False
)
return track
def update(self, obj, validated_data):
......@@ -1058,6 +1593,50 @@ class TrackSerializer(MusicEntitySerializer):
return super().update(obj, validated_data)
def duration_int_to_xml(duration):
if not duration:
return None
multipliers = {"S": 1, "M": 60, "H": 3600, "D": 86400}
ret = "P"
days, seconds = divmod(int(duration), multipliers["D"])
ret += f"{days:d}DT" if days > 0 else "T"
hours, seconds = divmod(seconds, multipliers["H"])
ret += f"{hours:d}H" if hours > 0 else ""
minutes, seconds = divmod(seconds, multipliers["M"])
ret += f"{minutes:d}M" if minutes > 0 else ""
ret += f"{seconds:d}S" if seconds > 0 or ret == "PT" else ""
return ret
class DayTimeDurationSerializer(serializers.DurationField):
multipliers = {"S": 1, "M": 60, "H": 3600, "D": 86400}
def to_internal_value(self, value):
if isinstance(value, float):
return value
parsed = re.match(
r"P([0-9]+D)?T([0-9]+H)?([0-9]+M)?([0-9]+(?:\.[0-9]+)?S)?", str(value)
)
if parsed is not None:
return int(
sum(
[
self.multipliers[s[-1]] * float("0" + s[:-1])
for s in parsed.groups()
if s is not None
]
)
)
self.fail(
"invalid", format="https://www.w3.org/TR/xmlschema11-2/#dayTimeDuration"
)
def to_representation(self, value):
duration_int_to_xml(value)
class UploadSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Audio])
id = serializers.URLField(max_length=500)
......@@ -1067,7 +1646,7 @@ class UploadSerializer(jsonld.JsonLdSerializer):
updated = serializers.DateTimeField(required=False, allow_null=True)
bitrate = serializers.IntegerField(min_value=0)
size = serializers.IntegerField(min_value=0)
duration = serializers.IntegerField(min_value=0)
duration = DayTimeDurationSerializer(min_value=0)
track = TrackSerializer(required=True)
......@@ -1103,25 +1682,53 @@ class UploadSerializer(jsonld.JsonLdSerializer):
def validate_library(self, v):
lb = self.context.get("library")
if lb:
if lb.fid != v:
raise serializers.ValidationError("Invalid library")
# the upload can come from a playlist lib
if lb.fid != v and not lb.playlist.library and lb.playlist.library.fid != v:
raise serializers.ValidationError("Invalid library fid")
return lb
actor = self.context.get("actor")
kwargs = {}
if actor:
kwargs["actor"] = actor
try:
return music_models.Library.objects.get(fid=v, **kwargs)
except music_models.Library.DoesNotExist:
raise serializers.ValidationError("Invalid library")
library = utils.retrieve_ap_object(
v,
actor=self.context.get("fetch_actor"),
queryset=music_models.Library,
serializer_class=LibrarySerializer,
)
except Exception as e:
raise serializers.ValidationError(f"Invalid library : {e}")
if actor and library.actor != actor:
raise serializers.ValidationError("Invalid library, actor check fails")
return library
def update(self, instance, validated_data):
return self.create(validated_data)
@transaction.atomic
def create(self, validated_data):
instance = self.instance or None
if not self.instance:
try:
return music_models.Upload.objects.get(fid=validated_data["id"])
instance = music_models.Upload.objects.get(fid=validated_data["id"])
except music_models.Upload.DoesNotExist:
pass
if instance:
data = {
"mimetype": validated_data["url"]["mediaType"],
"source": validated_data["url"]["href"],
"creation_date": validated_data["published"],
"modification_date": validated_data.get("updated"),
"duration": validated_data["duration"],
"size": validated_data["size"],
"bitrate": validated_data["bitrate"],
"import_status": "finished",
}
return music_models.Upload.objects.update_or_create(
fid=validated_data["id"], defaults=data
)[0]
else:
track = TrackSerializer(
context={"activity": self.context.get("activity")}
).create(validated_data["track"])
......@@ -1143,22 +1750,32 @@ class UploadSerializer(jsonld.JsonLdSerializer):
return music_models.Upload.objects.create(**data)
def to_representation(self, instance):
lib = instance.library if instance.library else self.context.get("library")
track = instance.track
d = {
"type": "Audio",
"id": instance.get_federation_id(),
"library": instance.library.fid,
"library": lib.fid,
"name": track.full_name,
"published": instance.creation_date.isoformat(),
"bitrate": instance.bitrate,
"size": instance.size,
"duration": instance.duration,
"url": {
"href": utils.full_url(instance.listen_url),
"duration": duration_int_to_xml(instance.duration),
"url": [
{
"href": utils.full_url(instance.listen_url_no_download),
"type": "Link",
"mediaType": instance.mimetype,
},
{
"type": "Link",
"mediaType": "text/html",
"href": utils.full_url(instance.track.get_absolute_url()),
},
],
"track": TrackSerializer(track, context={"include_ap_context": False}).data,
"to": (contexts.AS.Public if lib.privacy_level == "everyone" else ""),
"attributedTo": lib.actor.fid,
}
if instance.modification_date:
d["updated"] = instance.modification_date.isoformat()
......@@ -1175,6 +1792,84 @@ class ActorDeleteSerializer(jsonld.JsonLdSerializer):
jsonld_mapping = {"fid": jsonld.first_id(contexts.AS.object)}
class FlagSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Flag])
id = serializers.URLField(max_length=500)
object = serializers.URLField(max_length=500)
content = serializers.CharField(required=False, allow_null=True, allow_blank=True)
actor = serializers.URLField(max_length=500)
type = serializers.ListField(
child=TagSerializer(), min_length=0, required=False, allow_null=True
)
class Meta:
jsonld_mapping = {
"object": jsonld.first_id(contexts.AS.object),
"content": jsonld.first_val(contexts.AS.content),
"actor": jsonld.first_id(contexts.AS.actor),
"type": jsonld.raw(contexts.AS.tag),
}
def validate_object(self, v):
try:
return utils.get_object_by_fid(v, local=True)
except ObjectDoesNotExist:
raise serializers.ValidationError(f"Unknown id {v} for reported object")
def validate_type(self, tags):
if tags:
for tag in tags:
if tag["name"] in dict(moderation_models.REPORT_TYPES):
return tag["name"]
return "other"
def validate_actor(self, v):
try:
return models.Actor.objects.get(fid=v, domain=self.context["actor"].domain)
except models.Actor.DoesNotExist:
raise serializers.ValidationError("Invalid actor")
def validate(self, data):
validated_data = super().validate(data)
return validated_data
def create(self, validated_data):
kwargs = {
"target": validated_data["object"],
"target_owner": moderation_serializers.get_target_owner(
validated_data["object"]
),
"target_state": moderation_serializers.get_target_state(
validated_data["object"]
),
"type": validated_data.get("type", "other"),
"summary": validated_data.get("content"),
"submitter": validated_data["actor"],
}
report, created = moderation_models.Report.objects.update_or_create(
fid=validated_data["id"],
defaults=kwargs,
)
moderation_signals.report_created.send(sender=None, report=report)
return report
def to_representation(self, instance):
d = {
"type": "Flag",
"id": instance.get_federation_id(),
"actor": actors.get_service_actor().fid,
"object": [instance.target.fid],
"content": instance.summary,
"tag": [repr_tag(instance.type)],
}
if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context()
return d
class NodeInfoLinkSerializer(serializers.Serializer):
href = serializers.URLField()
rel = serializers.URLField()
......@@ -1182,3 +1877,620 @@ class NodeInfoLinkSerializer(serializers.Serializer):
class NodeInfoSerializer(serializers.Serializer):
links = serializers.ListField(child=NodeInfoLinkSerializer(), min_length=1)
class ChannelOutboxSerializer(PaginatedCollectionSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.OrderedCollection])
class Meta:
jsonld_mapping = PAGINATED_COLLECTION_JSONLD_MAPPING
def to_representation(self, channel):
conf = {
"id": channel.actor.outbox_url,
"page_size": 100,
"attributedTo": channel.actor,
"actor": channel.actor,
"items": channel.library.uploads.for_federation()
.order_by("-creation_date")
.filter(track__artist_credit__artist=channel.artist),
"type": "OrderedCollection",
}
r = super().to_representation(conf)
return r
class ChannelUploadSerializer(jsonld.JsonLdSerializer):
id = serializers.URLField(max_length=500)
type = serializers.ChoiceField(choices=[contexts.AS.Audio])
url = LinkListSerializer(keep_mediatype=["audio/*"], min_length=1)
name = serializers.CharField()
published = serializers.DateTimeField(required=False)
duration = DayTimeDurationSerializer(required=False)
position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
disc = serializers.IntegerField(min_value=1, allow_null=True, required=False)
album = serializers.URLField(max_length=500, required=False)
license = serializers.URLField(allow_null=True, required=False)
attributedTo = serializers.URLField(max_length=500, required=False)
copyright = serializers.CharField(
allow_null=True,
required=False,
)
image = ImageSerializer(
allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=True,
)
mediaType = serializers.ChoiceField(
choices=common_models.CONTENT_TEXT_SUPPORTED_TYPES,
default="text/html",
required=False,
)
content = TruncatedCharField(
truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH,
required=False,
allow_blank=True,
allow_null=True,
)
tags = serializers.ListField(
child=TagSerializer(), min_length=0, required=False, allow_null=True
)
class Meta:
jsonld_mapping = {
"name": jsonld.first_val(contexts.AS.name),
"url": jsonld.raw(contexts.AS.url),
"published": jsonld.first_val(contexts.AS.published),
"mediaType": jsonld.first_val(contexts.AS.mediaType),
"content": jsonld.first_val(contexts.AS.content),
"duration": jsonld.first_val(contexts.AS.duration),
"album": jsonld.first_id(contexts.FW.album),
"copyright": jsonld.first_val(contexts.FW.copyright),
"disc": jsonld.first_val(contexts.FW.disc),
"license": jsonld.first_id(contexts.FW.license),
"position": jsonld.first_val(contexts.FW.position),
"image": jsonld.first_obj(contexts.AS.image),
"tags": jsonld.raw(contexts.AS.tag),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
}
def _validate_album(self, v):
return utils.retrieve_ap_object(
v,
actor=actors.get_service_actor(),
serializer_class=AlbumSerializer,
queryset=music_models.Album.objects.filter(
artist_credit__artist__channel=self.context["channel"]
),
)
def validate(self, data):
if not self.context.get("channel"):
if not data.get("attributedTo"):
raise serializers.ValidationError(
"Missing channel context and no attributedTo available"
)
actor = actors.get_actor(data["attributedTo"])
if not actor.get_channel():
raise serializers.ValidationError("Not a channel")
self.context["channel"] = actor.get_channel()
if data.get("album"):
data["album"] = self._validate_album(data["album"])
validated_data = super().validate(data)
if data.get("content"):
validated_data["description"] = {
"content_type": data["mediaType"],
"text": data["content"],
}
return validated_data
def to_representation(self, upload):
data = {
"id": upload.fid,
"type": "Audio",
"name": upload.track.title,
"attributedTo": upload.library.channel.actor.fid,
"published": upload.creation_date.isoformat(),
"to": (
contexts.AS.Public if upload.library.privacy_level == "everyone" else ""
),
"url": [
{
"type": "Link",
"mediaType": "text/html",
"href": utils.full_url(upload.track.get_absolute_url()),
},
{
"type": "Link",
"mediaType": upload.mimetype,
"href": utils.full_url(upload.listen_url_no_download),
},
],
}
if upload.track.album:
data["album"] = upload.track.album.fid
if upload.track.local_license:
data["license"] = upload.track.local_license["identifiers"][0]
include_if_not_none(data, duration_int_to_xml(upload.duration), "duration")
include_if_not_none(data, upload.track.position, "position")
include_if_not_none(data, upload.track.disc_number, "disc")
include_if_not_none(data, upload.track.copyright, "copyright")
include_if_not_none(data["url"][1], upload.bitrate, "bitrate")
include_if_not_none(data["url"][1], upload.size, "size")
include_content(data, upload.track.description)
include_image(data, upload.track.attachment_cover)
tags = [item.tag.name for item in upload.get_all_tagged_items()]
if tags:
data["tag"] = [repr_tag(name) for name in sorted(set(tags))]
data["summary"] = " ".join([f"#{name}" for name in tags])
if self.context.get("include_ap_context", True):
data["@context"] = jsonld.get_default_context()
return data
def update(self, instance, validated_data):
return self.update_or_create(validated_data)
@transaction.atomic
def update_or_create(self, validated_data):
channel = self.context["channel"]
now = timezone.now()
track_defaults = {
"fid": validated_data["id"],
"position": validated_data.get("position", 1),
"disc_number": validated_data.get("disc", 1),
"title": validated_data["name"],
"copyright": validated_data.get("copyright"),
"attributed_to": channel.attributed_to,
"album": validated_data.get("album"),
"creation_date": validated_data.get("published", now),
}
if validated_data.get("license"):
track_defaults["license"] = licenses.match(validated_data["license"])
track, created = music_models.Track.objects.update_or_create(
fid=validated_data["id"],
defaults=track_defaults,
)
# only one artist_credit per channel
query = (
Q(
artist=channel.artist,
)
& Q(credit__iexact=channel.artist.name)
& Q(joinphrase="")
)
defaults = {
"artist": channel.artist,
"joinphrase": "",
"credit": channel.artist.name,
}
ac_obj = music_tasks.get_best_candidate_or_create(
music_models.ArtistCredit,
query,
defaults=defaults,
sort_fields=["mbid", "fid"],
)
track.artist_credit.set([ac_obj[0].id])
if "image" in validated_data:
new_value = self.validated_data["image"]
common_utils.attach_file(
track,
"attachment_cover",
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None
),
)
common_utils.attach_content(
track, "description", validated_data.get("description")
)
tags = [t["name"] for t in validated_data.get("tags", []) or []]
tags_models.set_tags(track, *tags)
upload_defaults = {
"fid": validated_data["id"],
"track": track,
"library": channel.library,
"creation_date": validated_data.get("published", now),
"duration": validated_data.get("duration"),
"bitrate": validated_data["url"][0].get("bitrate"),
"size": validated_data["url"][0].get("size"),
"mimetype": validated_data["url"][0]["mediaType"],
"source": validated_data["url"][0]["href"],
"import_status": "finished",
}
upload, created = music_models.Upload.objects.update_or_create(
fid=validated_data["id"], defaults=upload_defaults
)
return upload
def create(self, validated_data):
return self.update_or_create(validated_data)
class ChannelCreateUploadSerializer(jsonld.JsonLdSerializer):
object = serializers.DictField()
class Meta:
jsonld_mapping = {
"object": jsonld.first_obj(contexts.AS.object),
}
def to_representation(self, upload):
payload = {
"@context": jsonld.get_default_context(),
"type": self.context.get("type", "Create"),
"id": utils.full_url(
reverse(
"federation:music:uploads-activity", kwargs={"uuid": upload.uuid}
)
),
"actor": upload.library.channel.actor.fid,
"object": ChannelUploadSerializer(
upload, context={"include_ap_context": False}
).data,
}
if self.context.get("activity_id_suffix"):
payload["id"] = os.path.join(
payload["id"], self.context["activity_id_suffix"]
)
return payload
def validate(self, validated_data):
serializer = ChannelUploadSerializer(
data=validated_data["object"], context=self.context, jsonld_expand=False
)
serializer.is_valid(raise_exception=True)
return {"audio_serializer": serializer}
def save(self, **kwargs):
return self.validated_data["audio_serializer"].save(**kwargs)
class DeleteSerializer(jsonld.JsonLdSerializer):
object = serializers.URLField(max_length=500)
type = serializers.ChoiceField(choices=[contexts.AS.Delete])
class Meta:
jsonld_mapping = {"object": jsonld.first_id(contexts.AS.object)}
def validate_object(self, url):
try:
obj = utils.get_object_by_fid(url)
except utils.ObjectDoesNotExist:
raise serializers.ValidationError(f"No object matching {url}")
if isinstance(obj, music_models.Upload):
obj = obj.track
return obj
def validate(self, validated_data):
if not utils.can_manage(
validated_data["object"].attributed_to, self.context["actor"]
):
raise serializers.ValidationError("You cannot delete this object")
return validated_data
class IndexSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(
choices=[contexts.AS.Collection, contexts.AS.OrderedCollection]
)
totalItems = serializers.IntegerField(min_value=0)
id = serializers.URLField(max_length=500)
first = serializers.URLField(max_length=500)
last = serializers.URLField(max_length=500)
class Meta:
jsonld_mapping = PAGINATED_COLLECTION_JSONLD_MAPPING
def to_representation(self, conf):
paginator = Paginator(conf["items"], conf["page_size"])
first = common_utils.set_query_parameter(conf["id"], page=1)
current = first
last = common_utils.set_query_parameter(conf["id"], page=paginator.num_pages)
d = {
"id": conf["id"],
"totalItems": paginator.count,
"type": "OrderedCollection",
"current": current,
"first": first,
"last": last,
}
if self.context.get("include_ap_context", True):
d["@context"] = jsonld.get_default_context()
return d
class TrackFavoriteSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Like])
id = serializers.URLField(max_length=500)
object = serializers.URLField(max_length=500)
actor = serializers.URLField(max_length=500)
audience = serializers.CharField(max_length=500)
class Meta:
jsonld_mapping = {
"object": jsonld.first_id(contexts.AS.object),
"actor": jsonld.first_id(contexts.AS.actor),
"audience": jsonld.first_id(contexts.AS.audience),
}
def to_representation(self, favorite):
payload = {
"type": "Like",
"id": favorite.fid,
"actor": favorite.actor.fid,
"object": favorite.track.fid,
"audience": favorite.privacy_level,
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
return payload
def create(self, validated_data):
actor = actors.get_actor(validated_data["actor"])
track = utils.retrieve_ap_object(
validated_data["object"],
actor=actors.get_service_actor(),
serializer_class=TrackSerializer,
)
return favorites_models.TrackFavorite.objects.create(
fid=validated_data.get("id"),
uuid=uuid.uuid4(),
actor=actor,
track=track,
privacy_level=validated_data["audience"],
)
class ListeningSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Listen])
id = serializers.URLField(max_length=500)
object = serializers.URLField(max_length=500)
actor = serializers.URLField(max_length=500)
audience = serializers.CharField(max_length=500)
class Meta:
jsonld_mapping = {
"object": jsonld.first_id(contexts.AS.object),
"actor": jsonld.first_id(contexts.AS.actor),
"audience": jsonld.first_id(contexts.AS.audience),
}
def to_representation(self, listening):
payload = {
"type": "Listen",
"id": listening.fid,
"actor": listening.actor.fid,
"object": listening.track.fid,
"audience": listening.privacy_level,
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
return payload
def create(self, validated_data):
actor = actors.get_actor(validated_data["actor"])
track = utils.retrieve_ap_object(
validated_data["object"],
actor=actors.get_service_actor(),
serializer_class=TrackSerializer,
)
return history_models.Listening.objects.create(
fid=validated_data.get("id"),
uuid=validated_data["id"].rstrip("/").split("/")[-1],
actor=actor,
track=track,
privacy_level=validated_data["audience"],
)
class PlaylistTrackSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.FW.PlaylistTrack])
id = serializers.URLField(max_length=500)
track = serializers.URLField(max_length=500)
index = serializers.IntegerField()
creation_date = serializers.DateTimeField()
playlist = serializers.URLField(max_length=500, required=False)
class Meta:
model = playlists_models.PlaylistTrack
jsonld_mapping = {
"track": jsonld.first_id(contexts.FW.track),
"playlist": jsonld.first_id(contexts.FW.playlist),
"index": jsonld.first_val(contexts.FW.index),
"creation_date": jsonld.first_val(contexts.AS.published),
}
def to_representation(self, plt):
payload = {
"type": "PlaylistTrack",
"id": plt.fid,
"track": plt.track.fid,
"index": plt.index,
"attributedTo": plt.playlist.actor.fid,
"published": plt.creation_date.isoformat(),
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
if self.context.get("include_playlist", True):
payload["playlist"] = plt.playlist.fid
return payload
def create(self, validated_data):
track = utils.retrieve_ap_object(
validated_data["track"],
actor=self.context.get("fetch_actor"),
queryset=music_models.Track,
serializer_class=TrackSerializer,
)
playlist = utils.retrieve_ap_object(
validated_data["playlist"],
actor=self.context.get("fetch_actor"),
queryset=playlists_models.Playlist,
serializer_class=PlaylistSerializer,
)
defaults = {
"track": track,
"index": validated_data["index"],
"creation_date": validated_data["creation_date"],
"playlist": playlist,
}
if existing_plt := playlists_models.PlaylistTrack.objects.filter(
playlist=playlist, index=validated_data["index"]
):
existing_plt.delete()
plt, created = playlists_models.PlaylistTrack.objects.update_or_create(
defaults,
**{
"uuid": validated_data["id"].rstrip("/").split("/")[-1],
"fid": validated_data["id"],
},
)
return plt
class PlaylistSerializer(jsonld.JsonLdSerializer):
"""
Used for playlist activities
"""
type = serializers.ChoiceField(choices=[contexts.FW.Playlist, contexts.AS.Create])
id = serializers.URLField(max_length=500)
uuid = serializers.UUIDField(required=False)
name = serializers.CharField(required=False)
attributedTo = serializers.URLField(max_length=500, required=False)
published = serializers.DateTimeField(required=False)
updated = serializers.DateTimeField(required=False)
audience = serializers.ChoiceField(
choices=[None, "https://www.w3.org/ns/activitystreams#Public"],
required=False,
allow_null=True,
allow_blank=True,
)
library = serializers.URLField(max_length=500, required=True)
updateable_fields = [
("name", "title"),
("attributedTo", "attributed_to"),
]
class Meta:
model = playlists_models.Playlist
jsonld_mapping = common_utils.concat_dicts(
MUSIC_ENTITY_JSONLD_MAPPING,
{
"updated": jsonld.first_val(contexts.AS.published),
"audience": jsonld.first_id(contexts.AS.audience),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
"library": jsonld.first_id(contexts.FW.library),
},
)
def to_representation(self, playlist):
payload = {
"type": "Playlist",
"id": playlist.fid,
"name": playlist.name,
"attributedTo": playlist.actor.fid,
"published": playlist.creation_date.isoformat(),
"audience": playlist.privacy_level,
"library": playlist.library.fid,
}
payload["audience"] = (
contexts.AS.Public if playlist.privacy_level == "everyone" else ""
)
if playlist.modification_date:
payload["updated"] = playlist.modification_date.isoformat()
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
return payload
def create(self, validated_data):
actor = utils.retrieve_ap_object(
validated_data["attributedTo"],
actor=self.context.get("fetch_actor"),
queryset=models.Actor,
serializer_class=ActorSerializer,
)
library = utils.retrieve_ap_object(
validated_data["library"],
actor=self.context.get("fetch_actor"),
queryset=music_models.Library,
serializer_class=LibrarySerializer,
)
ap_to_fw_data = {
"actor": actor,
"name": validated_data["name"],
"creation_date": validated_data["published"],
"privacy_level": validated_data["audience"],
"library": library,
}
playlist, created = playlists_models.Playlist.objects.update_or_create(
defaults=ap_to_fw_data,
**{
"fid": validated_data["id"],
"uuid": validated_data.get(
"uuid", validated_data["id"].rstrip("/").split("/")[-1]
),
},
)
return playlist
def validate(self, data):
validated_data = super().validate(data)
if validated_data["audience"] in [
"https://www.w3.org/ns/activitystreams#Public",
"everyone",
]:
validated_data["audience"] = "everyone"
else:
validated_data.pop("audience")
return validated_data
def update(self, instance, validated_data):
return self.create(validated_data)
class PlaylistCollectionSerializer(PaginatedCollectionSerializer):
"""
Used for the federation view.
"""
type = serializers.ChoiceField(choices=[contexts.FW.Playlist])
def to_representation(self, playlist):
conf = {
"id": playlist.fid,
"name": playlist.name,
"page_size": 100,
"actor": playlist.actor,
"items": playlist.playlist_tracks.order_by("index").prefetch_related(
"tracks",
),
"type": "Playlist",
"library": playlist.library.fid,
"published": playlist.creation_date.isoformat(),
}
r = super().to_representation(conf)
return r
import datetime
import logging
import pytz
import sys
import cryptography.exceptions
import requests
import requests_http_message_signatures
from django import forms
from django.utils import timezone
from django.utils.http import parse_http_date
import requests
import requests_http_signature
from . import exceptions, utils
if sys.version_info < (3, 9):
from backports.zoneinfo import ZoneInfo
else:
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
# the request Date should be between now - 30s and now + 30s
......@@ -25,11 +30,14 @@ def verify_date(raw_date):
ts = parse_http_date(raw_date)
except ValueError as e:
raise forms.ValidationError(str(e))
dt = datetime.datetime.utcfromtimestamp(ts)
dt = dt.replace(tzinfo=pytz.utc)
dt = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc)
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
delta = datetime.timedelta(seconds=DATE_HEADER_VALID_FOR)
now = timezone.now()
if dt < now - delta or dt > now + delta:
logger.debug(
f"Request Date {raw_date} is too too far in the future or in the past"
)
raise forms.ValidationError(
"Request Date is too far in the future or in the past"
)
......@@ -38,11 +46,22 @@ def verify_date(raw_date):
def verify(request, public_key):
verify_date(request.headers.get("Date"))
return requests_http_signature.HTTPSignatureAuth.verify(
request, key_resolver=lambda **kwargs: public_key, use_auth_header=False
date = request.headers.get("Date")
logger.debug(
"Verifying request with date %s and headers %s", date, str(request.headers)
)
verify_date(date)
try:
return requests_http_message_signatures.HTTPSignatureHeaderAuth.verify(
request, key_resolver=lambda **kwargs: public_key
)
except cryptography.exceptions.InvalidSignature:
logger.warning(
"Could not verify request with date %s and headers %s",
date,
str(request.headers),
)
raise
def verify_django(django_request, public_key):
......@@ -53,16 +72,16 @@ def verify_django(django_request, public_key):
headers = utils.clean_wsgi_headers(django_request.META)
for h, v in list(headers.items()):
# we include lower-cased version of the headers for compatibility
# with requests_http_signature
# with requests_http_message_signatures
headers[h.lower()] = v
try:
signature = headers["Signature"]
except KeyError:
raise exceptions.MissingSignature
url = "http://noop{}".format(django_request.path)
url = f"http://noop{django_request.path}"
query = django_request.META["QUERY_STRING"]
if query:
url += "?{}".format(query)
url += f"?{query}"
signature_headers = signature.split('headers="')[1].split('",')[0]
expected = signature_headers.split(" ")
logger.debug("Signature expected headers: %s", expected)
......@@ -86,8 +105,7 @@ def verify_django(django_request, public_key):
def get_auth(private_key, private_key_id):
return requests_http_signature.HTTPSignatureAuth(
use_auth_header=False,
return requests_http_message_signatures.HTTPSignatureHeaderAuth(
headers=["(request-target)", "user-agent", "host", "date"],
algorithm="rsa-sha256",
key=private_key.encode("utf-8"),
......
from django.conf import settings
from rest_framework import serializers
from funkwhale_api.common import middleware, preferences, utils
from funkwhale_api.federation import utils as federation_utils
from . import models
def actor_detail_username(request, username, redirect_to_ap):
validator = federation_utils.get_actor_data_from_username
try:
username_data = validator(username)
except serializers.ValidationError:
return []
queryset = (
models.Actor.objects.filter(
preferred_username__iexact=username_data["username"]
)
.local()
.select_related("attachment_icon")
)
try:
obj = queryset.get()
except models.Actor.DoesNotExist:
return []
if redirect_to_ap:
raise middleware.ApiRedirect(obj.fid)
obj_url = utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("actor_detail", kwargs={"username": obj.preferred_username}),
)
metas = [
{"tag": "meta", "property": "og:url", "content": obj_url},
{"tag": "meta", "property": "og:title", "content": obj.display_name},
{"tag": "meta", "property": "og:type", "content": "profile"},
]
if obj.attachment_icon:
metas.append(
{
"tag": "meta",
"property": "og:image",
"content": obj.attachment_icon.download_url_medium_square_crop,
}
)
if preferences.get("federation__enabled"):
metas.append(
{
"tag": "link",
"rel": "alternate",
"type": "application/activity+json",
"href": obj.fid,
}
)
return metas
......@@ -2,28 +2,40 @@ import datetime
import json
import logging
import os
import requests
from urllib.parse import urlparse
import requests
from django.conf import settings
from django.core.cache import cache
from django.db import transaction
from django.db.models import Q, F
from django.db.models import F, Q
from django.db.models.deletion import Collector
from django.utils import timezone
from dynamic_preferences.registries import global_preferences_registry
from requests.exceptions import RequestException
from funkwhale_api.common import preferences
from funkwhale_api.common import session
from funkwhale_api.audio import models as audio_models
from funkwhale_api.common import models as common_models
from funkwhale_api.common import preferences, session
from funkwhale_api.common import utils as common_utils
from funkwhale_api.moderation import mrf
from funkwhale_api.music import models as music_models
from funkwhale_api.playlists import models as playlists_models
from funkwhale_api.taskapp import celery
from . import actors
from . import jsonld
from . import keys
from . import models, signing
from . import serializers
from . import routes
from . import utils
from . import (
activity,
actors,
exceptions,
jsonld,
keys,
models,
routes,
serializers,
signing,
utils,
webfinger,
)
logger = logging.getLogger(__name__)
......@@ -44,6 +56,7 @@ def clean_music_cache():
)
.local(False)
.exclude(audio_file="")
.filter(Q(source__startswith="http://") | Q(source__startswith="https://"))
.only("audio_file", "id")
.order_by("id")
)
......@@ -129,11 +142,23 @@ def dispatch_outbox(activity):
"delivery",
)
def deliver_to_remote(delivery):
if not preferences.get("federation__enabled"):
# federation is disabled, we only deliver to local recipients
return
# we check the domain is still reachable before attempting delivery
if (
models.Domain.objects.get(name=urlparse(delivery.inbox_url).netloc).reachable
is False
):
delivery.last_attempt_date = timezone.now()
delivery.attempts = F("attempts") + 1
delivery.save(update_fields=["last_attempt_date", "attempts"])
logger.info(
f"Skipping delivery to {delivery.inbox_url} as its domain is unreachable",
)
return
actor = delivery.activity.actor
logger.info("Preparing activity delivery to %s", delivery.inbox_url)
auth = signing.get_auth(actor.private_key, actor.private_key_id)
......@@ -160,7 +185,7 @@ def deliver_to_remote(delivery):
def fetch_nodeinfo(domain_name):
s = session.get_session()
wellknown_url = "https://{}/.well-known/nodeinfo".format(domain_name)
wellknown_url = f"https://{domain_name}/.well-known/nodeinfo"
response = s.get(url=wellknown_url)
response.raise_for_status()
serializer = serializers.NodeInfoSerializer(data=response.json())
......@@ -198,14 +223,18 @@ def update_domain_nodeinfo(domain):
domain.service_actor = (
utils.retrieve_ap_object(
service_actor_id,
actor=actors.get_service_actor(),
actor=None,
queryset=models.Actor,
serializer_class=serializers.ActorSerializer,
)
if service_actor_id
else None
)
except (serializers.serializers.ValidationError, RequestException) as e:
except (
serializers.serializers.ValidationError,
RequestException,
exceptions.BlockedActorOrDomain,
) as e:
logger.warning(
"Cannot fetch system actor for domain %s: %s", domain.name, str(e)
)
......@@ -221,8 +250,10 @@ def refresh_nodeinfo_known_nodes():
settings.NODEINFO_REFRESH_DELAY
"""
limit = timezone.now() - datetime.timedelta(seconds=settings.NODEINFO_REFRESH_DELAY)
candidates = models.Domain.objects.external().exclude(
nodeinfo_fetch_date__gte=limit
candidates = (
models.Domain.objects.external()
.exclude(nodeinfo_fetch_date__gte=limit)
.filter(nodeinfo__software__name="Funkwhale")
)
names = candidates.values_list("name", flat=True)
logger.info("Launching periodic nodeinfo refresh on %s domains", len(names))
......@@ -252,8 +283,11 @@ def handle_purge_actors(ids, only=[]):
# purge audio content
if not only or "media" in only:
delete_qs(common_models.Attachment.objects.filter(actor__in=ids))
delete_qs(models.LibraryFollow.objects.filter(actor_id__in=ids))
delete_qs(models.Follow.objects.filter(target_id__in=ids))
delete_qs(audio_models.Channel.objects.filter(attributed_to__in=ids))
delete_qs(audio_models.Channel.objects.filter(actor__in=ids))
delete_qs(music_models.Upload.objects.filter(library__actor_id__in=ids))
delete_qs(music_models.Library.objects.filter(actor_id__in=ids))
......@@ -285,29 +319,56 @@ def rotate_actor_key(actor):
@celery.app.task(name="federation.fetch")
@transaction.atomic
@celery.require_instance(
models.Fetch.objects.filter(status="pending").select_related("actor"), "fetch"
models.Fetch.objects.filter(status="pending").select_related("actor"),
"fetch_obj",
"fetch_id",
)
def fetch(fetch):
actor = fetch.actor
auth = signing.get_auth(actor.private_key, actor.private_key_id)
def fetch(fetch_obj):
def error(code, **kwargs):
fetch.status = "errored"
fetch.fetch_date = timezone.now()
fetch.detail = {"error_code": code}
fetch.detail.update(kwargs)
fetch.save(update_fields=["fetch_date", "status", "detail"])
fetch_obj.status = "errored"
fetch_obj.fetch_date = timezone.now()
fetch_obj.detail = {"error_code": code}
fetch_obj.detail.update(kwargs)
fetch_obj.save(update_fields=["fetch_date", "status", "detail"])
url = fetch_obj.url
mrf_check_url = url
if not mrf_check_url.startswith("webfinger://"):
payload, updated = mrf.inbox.apply({"id": mrf_check_url})
if not payload:
return error("blocked", message="Blocked by MRF")
actor = fetch_obj.actor
if settings.FEDERATION_AUTHENTIFY_FETCHES:
auth = signing.get_auth(actor.private_key, actor.private_key_id)
else:
auth = None
try:
if url.startswith("webfinger://"):
# we first grab the corresponding webfinger representation
# to get the ActivityPub actor ID
webfinger_data = webfinger.get_resource(
"acct:" + url.replace("webfinger://", "")
)
url = webfinger.get_ap_url(webfinger_data["links"])
if not url:
return error("webfinger", message="Invalid or missing webfinger data")
payload, updated = mrf.inbox.apply({"id": url})
if not payload:
return error("blocked", message="Blocked by MRF")
response = session.get_session().get(
auth=auth,
url=fetch.url,
headers={"Content-Type": "application/activity+json"},
url=url,
headers={"Accept": "application/activity+json"},
)
logger.debug("Remote answered with %s", response.status_code)
logger.debug("Remote answered with %s: %s", response.status_code, response.text)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
return error("http", status_code=e.response.status_code if e.response else None)
return error(
"http",
status_code=e.response.status_code if e.response else None,
message=e.response.text,
)
except requests.exceptions.Timeout:
return error("timeout")
except requests.exceptions.ConnectionError as e:
......@@ -320,8 +381,19 @@ def fetch(fetch):
try:
payload = response.json()
except json.decoder.JSONDecodeError:
# we attempt to extract a <link rel=alternate> that points
# to an activity pub resource, if possible, and retry with this URL
alternate_url = utils.find_alternate(response.text)
if alternate_url:
fetch_obj.url = alternate_url
fetch_obj.save(update_fields=["url"])
return fetch(fetch_id=fetch_obj.pk)
return error("invalid_json")
payload, updated = mrf.inbox.apply(payload)
if not payload:
return error("blocked", message="Blocked by MRF")
try:
doc = jsonld.expand(payload)
except ValueError:
......@@ -332,13 +404,13 @@ def fetch(fetch):
except IndexError:
return error("missing_jsonld_type")
try:
serializer_class = fetch.serializers[type]
model = serializer_class.Meta.model
serializer_classes = fetch_obj.serializers[type]
model = serializer_classes[0].Meta.model
except (KeyError, AttributeError):
fetch.status = "skipped"
fetch.fetch_date = timezone.now()
fetch.detail = {"reason": "unhandled_type", "type": type}
return fetch.save(update_fields=["fetch_date", "status", "detail"])
fetch_obj.status = "skipped"
fetch_obj.fetch_date = timezone.now()
fetch_obj.detail = {"reason": "unhandled_type", "type": type}
return fetch_obj.save(update_fields=["fetch_date", "status", "detail"])
try:
id = doc.get("@id")
except IndexError:
......@@ -346,15 +418,298 @@ def fetch(fetch):
else:
existing = model.objects.filter(fid=id).first()
serializer = serializer_class(existing, data=payload)
serializer = None
for serializer_class in serializer_classes:
serializer = serializer_class(
existing, data=payload, context={"fetch_actor": actor}
)
if not serializer.is_valid():
continue
else:
break
if serializer.errors:
return error("validation", validation_errors=serializer.errors)
try:
serializer.save()
obj = serializer.save()
except Exception as e:
error("save", message=str(e))
raise
fetch.status = "finished"
fetch.fetch_date = timezone.now()
return fetch.save(update_fields=["fetch_date", "status"])
# special case for channels
# when obj is an actor, we check if the actor has a channel associated with it
# if it is the case, we consider the fetch obj to be a channel instead
# and also trigger a fetch on the channel outbox
if isinstance(obj, models.Actor) and obj.get_channel():
obj = obj.get_channel()
if obj.actor.outbox_url:
try:
# first page fetch is synchronous, so that at least some data is available
# in the UI after subscription
result = fetch_collection(
obj.actor.outbox_url,
channel_id=obj.pk,
max_pages=1,
)
except Exception:
logger.exception(
"Error while fetching actor outbox: %s", obj.actor.outbox_url
)
else:
if result.get("next_page"):
# additional pages are fetched in the background
result = fetch_collection.delay(
result["next_page"],
channel_id=obj.pk,
max_pages=settings.FEDERATION_COLLECTION_MAX_PAGES - 1,
is_page=True,
)
fetch_obj.object = obj
fetch_obj.status = "finished"
fetch_obj.fetch_date = timezone.now()
return fetch_obj.save(
update_fields=["fetch_date", "status", "object_id", "object_content_type"]
)
class PreserveSomeDataCollector(Collector):
"""
We need to delete everything related to an actor. Well… Almost everything.
But definitely not the Delete Activity we send to announce the actor is deleted.
"""
def __init__(self, *args, **kwargs):
self.creation_date = timezone.now()
super().__init__(*args, **kwargs)
def related_objects(self, related, *args, **kwargs):
qs = super().related_objects(related, *args, **kwargs)
# We can only exclude the actions if these fields are available, most likely its a
# model.Activity than
if hasattr(related, "type") and hasattr(related, "creation_date"):
# exclude the delete activity can be broadcasted properly
qs = qs.exclude(type="Delete", creation_date__gte=self.creation_date)
return qs
@celery.app.task(name="federation.remove_actor")
@transaction.atomic
@celery.require_instance(
models.Actor.objects.all(),
"actor",
)
def remove_actor(actor):
# Then we broadcast the info over federation. We do this *before* deleting objects
# associated with the actor, otherwise follows are removed and we don't know where
# to broadcast
logger.info("Broadcasting deletion to federation…")
collector = PreserveSomeDataCollector(using="default")
routes.outbox.dispatch(
{"type": "Delete", "object": {"type": actor.type}}, context={"actor": actor}
)
# then we delete any object associated with the actor object, but *not* the actor
# itself. We keep it for auditability and sending the Delete ActivityPub message
logger.info(
"Prepare deletion of objects associated with account %s…",
actor.preferred_username,
)
collector.collect([actor])
for model, instances in collector.data.items():
if issubclass(model, actor.__class__):
# we skip deletion of the actor itself
continue
to_delete = model.objects.filter(pk__in=[instance.pk for instance in instances])
logger.info(
"Deleting %s objects associated with account %s…",
len(instances),
actor.preferred_username,
)
to_delete.delete()
# Finally, we update the actor itself and mark it as removed
logger.info("Marking actor as Tombsone…")
actor.type = "Tombstone"
actor.name = None
actor.summary = None
actor.save(update_fields=["type", "name", "summary"])
COLLECTION_ACTIVITY_SERIALIZERS = [
(
{"type": "Create", "object.type": "Audio"},
serializers.ChannelCreateUploadSerializer,
)
]
def match_serializer(payload, conf):
return [
serializer_class
for route, serializer_class in conf
if activity.match_route(route, payload)
]
@celery.app.task(name="federation.fetch_collection")
@celery.require_instance(
audio_models.Channel.objects.all(),
"channel",
allow_null=True,
)
def fetch_collection(url, max_pages, channel, is_page=False):
actor = actors.get_service_actor()
results = {
"items": [],
"skipped": 0,
"errored": 0,
"seen": 0,
"total": 0,
}
if is_page:
# starting immediately from a page, no need to fetch the wrapping collection
logger.debug("Fetch collection page immediately at %s", url)
results["next_page"] = url
else:
logger.debug("Fetching collection object at %s", url)
collection = utils.retrieve_ap_object(
url,
actor=actor,
serializer_class=serializers.PaginatedCollectionSerializer,
)
results["next_page"] = collection["first"]
results["total"] = collection.get("totalItems")
seen_pages = 0
context = {}
if channel:
context["channel"] = channel
for i in range(max_pages):
page_url = results["next_page"]
logger.debug("Handling page %s on max %s, at %s", i + 1, max_pages, page_url)
page = utils.retrieve_ap_object(
page_url,
actor=actor,
serializer_class=None,
)
try:
items = page["orderedItems"]
except KeyError:
try:
items = page["items"]
except KeyError:
logger.error("Invalid collection page at %s", page_url)
break
for item in items:
results["seen"] += 1
matching_serializer = match_serializer(
item, COLLECTION_ACTIVITY_SERIALIZERS
)
if not matching_serializer:
results["skipped"] += 1
logger.debug("Skipping unhandled activity %s", item.get("type"))
continue
s = matching_serializer[0](data=item, context=context)
if not s.is_valid():
logger.warn("Skipping invalid activity: %s", s.errors)
results["errored"] += 1
continue
results["items"].append(s.save())
seen_pages += 1
results["next_page"] = page.get("next", None) or None
if not results["next_page"]:
logger.debug("No more pages to fetch")
break
logger.info(
"Finished fetch of collection pages at %s. Results:\n"
" Total in collection: %s\n"
" Seen: %s\n"
" Handled: %s\n"
" Skipped: %s\n"
" Errored: %s",
url,
results.get("total"),
results["seen"],
len(results["items"]),
results["skipped"],
results["errored"],
)
return results
@celery.app.task(name="federation.check_all_remote_instance_availability")
def check_all_remote_instance_availability():
base_interval = 3600
factor = 1.15
for domain in models.Domain.objects.all():
if domain.name == settings.FUNKWHALE_HOSTNAME:
continue
attempt = domain.reachable_retries or 0
last_success = domain.last_successful_contact or domain.creation_date
delay_seconds = base_interval * (factor**attempt)
delay = datetime.timedelta(seconds=delay_seconds)
next_check_due = last_success + delay
now = timezone.now()
if domain.reachable is False and now < next_check_due:
logger.info(
f"[{domain.name}] Skipping check. Last successful: {last_success}, "
f"attempt #{attempt}, next check due in {delay} at {next_check_due}"
)
continue
check_single_remote_instance_availability(domain)
@celery.app.task(name="federation.check_single_remote_instance_availability")
def check_single_remote_instance_availability(domain):
try:
nodeinfo = fetch_nodeinfo(domain.name)
except Exception as e:
logger.info(
f"Domain {domain.name} could not be reached because of the following error : {e}. \
Setting domain as unreachable."
)
domain.reachable = False
domain.reachable_retries += 1
domain.save()
return domain.reachable
if "version" in nodeinfo.keys():
domain.reachable = True
domain.last_successful_contact = timezone.now()
domain.reachable_retries = 0
domain.save()
return domain.reachable
else:
logger.info(
f"Domain {domain.name} is not reachable at the moment. Setting domain as unreachable."
)
domain.reachable = False
domain.reachable_retries += 1
domain.save()
return domain.reachable
@celery.app.task(name="federation.trigger_playlist_ap_update")
def trigger_playlist_ap_update(playlist):
for playlist_uuid in cache.get("playlists_for_ap_update"):
routes.outbox.dispatch(
{"type": "Update", "object": {"type": "Playlist"}},
context={
"playlist": playlists_models.Playlist.objects.get(uuid=playlist_uuid)
},
)
from django.conf.urls import include, url
from django.conf.urls import include
from django.urls import re_path
from rest_framework import routers
from . import views
router = routers.SimpleRouter(trailing_slash=False)
music_router = routers.SimpleRouter(trailing_slash=False)
index_router = routers.SimpleRouter(trailing_slash=False)
router.register(r"federation/shared", views.SharedViewSet, "shared")
router.register(r"federation/actors", views.ActorViewSet, "actors")
......@@ -15,8 +17,20 @@ router.register(r".well-known", views.WellKnownViewSet, "well-known")
music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries")
music_router.register(r"uploads", views.MusicUploadViewSet, "uploads")
music_router.register(r"artists", views.MusicArtistViewSet, "artists")
music_router.register(r"artistcredit", views.MusicArtistCreditViewSet, "artistcredit")
music_router.register(r"albums", views.MusicAlbumViewSet, "albums")
music_router.register(r"tracks", views.MusicTrackViewSet, "tracks")
music_router.register(r"likes", views.TrackFavoriteViewSet, "likes")
music_router.register(r"listenings", views.ListeningsViewSet, "listenings")
music_router.register(r"playlists", views.PlaylistViewSet, "playlists")
music_router.register(r"playlists", views.PlaylistTrackViewSet, "playlist-tracks")
index_router.register(r"index", views.IndexViewSet, "index")
urlpatterns = router.urls + [
url("federation/music/", include((music_router.urls, "music"), namespace="music"))
re_path(
"federation/music/", include((music_router.urls, "music"), namespace="music")
),
re_path("federation/", include((index_router.urls, "index"), namespace="index")),
]
import unicodedata
import html.parser
import re
import unicodedata
import urllib.parse
from django.apps import apps
from django.conf import settings
from django.db.models import Q
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import CharField, Q, Value
from funkwhale_api.common import session
from funkwhale_api.moderation import mrf
from . import exceptions
from . import signing
from . import exceptions, signing
def full_url(path):
......@@ -62,7 +66,11 @@ def slugify_username(username):
def retrieve_ap_object(
fid, actor, serializer_class=None, queryset=None, apply_instance_policies=True
fid,
actor,
serializer_class=None,
queryset=None,
apply_instance_policies=True,
):
# we have a duplicate check here because it's less expensive to do those checks
# twice than to trigger a HTTP request
......@@ -102,7 +110,10 @@ def retrieve_ap_object(
return data
serializer = serializer_class(data=data, context={"fetch_actor": actor})
serializer.is_valid(raise_exception=True)
try:
return serializer.save()
except NotImplementedError:
return serializer.validated_data
def get_domain_query_from_url(domain, url_field="fid"):
......@@ -111,25 +122,29 @@ def get_domain_query_from_url(domain, url_field="fid"):
to match objects that have this domain in the given field.
"""
query = Q(**{"{}__startswith".format(url_field): "http://{}/".format(domain)})
query = query | Q(
**{"{}__startswith".format(url_field): "https://{}/".format(domain)}
)
query = Q(**{f"{url_field}__startswith": f"http://{domain}/"})
query = query | Q(**{f"{url_field}__startswith": f"https://{domain}/"})
return query
def is_local(url):
def local_qs(queryset, url_field="fid", include=True):
query = get_domain_query_from_url(
domain=settings.FEDERATION_HOSTNAME, url_field=url_field
)
if not include:
query = ~query
return queryset.filter(query)
def is_local(url) -> bool:
if not url:
return True
d = settings.FEDERATION_HOSTNAME
return url.startswith("http://{}/".format(d)) or url.startswith(
"https://{}/".format(d)
)
return url.startswith(f"http://{d}/") or url.startswith(f"https://{d}/")
def get_actor_data_from_username(username):
parts = username.split("@")
return {
......@@ -144,8 +159,8 @@ def get_actor_from_username_data_query(field, data):
if field:
return Q(
**{
"{}__preferred_username__iexact".format(field): data["username"],
"{}__domain__name__iexact".format(field): data["domain"],
f"{field}__preferred_username__iexact": data["username"],
f"{field}__domain__name__iexact": data["domain"],
}
)
else:
......@@ -155,3 +170,127 @@ def get_actor_from_username_data_query(field, data):
"domain__name__iexact": data["domain"],
}
)
class StopParsing(Exception):
pass
class AlternateLinkParser(html.parser.HTMLParser):
def __init__(self, *args, **kwargs):
self.result = None
super().__init__(*args, **kwargs)
def handle_starttag(self, tag, attrs):
if tag != "link":
return
attrs_dict = dict(attrs)
if attrs_dict.get("rel") == "alternate" and attrs_dict.get(
"type", "application/activity+json"
):
self.result = attrs_dict.get("href")
raise StopParsing()
def handle_endtag(self, tag):
if tag == "head":
raise StopParsing()
def find_alternate(response_text):
if not response_text:
return
parser = AlternateLinkParser()
try:
parser.feed(response_text)
except StopParsing:
return parser.result
def should_redirect_ap_to_html(accept_header, default=True):
if not accept_header:
return False
redirect_headers = [
"text/html",
]
no_redirect_headers = [
"application/json",
"application/activity+json",
"application/ld+json",
]
parsed_header = [ct.lower().strip() for ct in accept_header.split(",")]
for ct in parsed_header:
if ct in redirect_headers:
return True
if ct in no_redirect_headers:
return False
return default
FID_MODEL_LABELS = [
"music.Artist",
"music.Album",
"music.Track",
"music.Library",
"music.Upload",
"federation.Actor",
]
def get_object_by_fid(fid, local=None):
if local:
parsed = urllib.parse.urlparse(fid)
if parsed.netloc != settings.FEDERATION_HOSTNAME:
raise ObjectDoesNotExist()
models = [apps.get_model(*l.split(".")) for l in FID_MODEL_LABELS]
def get_qs(model):
return (
model.objects.all()
.filter(fid=fid)
.annotate(__type=Value(model._meta.label, output_field=CharField()))
.values("fid", "__type")
)
qs = get_qs(models[0])
for m in models[1:]:
qs = qs.union(get_qs(m))
result = qs.order_by("fid").first()
if not result:
raise ObjectDoesNotExist()
model = apps.get_model(*result["__type"].split("."))
instance = model.objects.get(fid=fid)
if model._meta.label == "federation.Actor":
channel = instance.get_channel()
if channel:
return channel
return instance
def can_manage(obj_owner, actor):
if not obj_owner:
return False
if not actor:
return False
if obj_owner == actor:
return True
if obj_owner.domain.service_actor == actor:
return True
return False
def update_actor_privacy(actor, privacy_level):
actor.track_favorites.update(privacy_level=privacy_level)
actor.listenings.update(privacy_level=privacy_level)
# to do : trigger federation privacy_level downgrade #2336
from django import forms
from django.conf import settings
from django.core import paginator
from django.db.models import Prefetch
from django.http import HttpResponse
......@@ -6,12 +7,61 @@ from django.urls import reverse
from rest_framework import exceptions, mixins, permissions, response, viewsets
from rest_framework.decorators import action
from funkwhale_api.common import permissions as common_permissions
from funkwhale_api.common import preferences
from funkwhale_api.common import utils as common_utils
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.history import models as history_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.music import models as music_models
from funkwhale_api.music import utils as music_utils
from funkwhale_api.playlists import models as playlists_models
from . import (
activity,
actors,
authentication,
models,
renderers,
serializers,
utils,
webfinger,
)
from . import activity, authentication, models, renderers, serializers, utils, webfinger
def redirect_to_html(public_url):
response = HttpResponse(status=302)
response["Location"] = common_utils.join_url(settings.FUNKWHALE_URL, public_url)
return response
def get_collection_response(
conf, querystring, collection_serializer, page_access_check=None
):
page = querystring.get("page")
if page is None:
data = collection_serializer.data
else:
if page_access_check and not page_access_check():
raise exceptions.AuthenticationFailed(
"You do not have access to this resource"
)
try:
page_number = int(page)
except Exception:
return response.Response({"page": ["Invalid page number"]}, status=400)
conf["page_size"] = preferences.get("federation__collection_page_size")
p = paginator.Paginator(conf["items"], conf["page_size"])
try:
page = p.page(page_number)
conf["page"] = page
serializer = serializers.CollectionPageSerializer(conf)
data = serializer.data
except paginator.EmptyPage:
return response.Response(status=404)
return response.Response(data)
class AuthenticatedIfAllowListEnabled(permissions.BasePermission):
......@@ -22,7 +72,7 @@ class AuthenticatedIfAllowListEnabled(permissions.BasePermission):
return bool(request.actor)
class FederationMixin(object):
class FederationMixin:
permission_classes = [AuthenticatedIfAllowListEnabled]
def dispatch(self, request, *args, **kwargs):
......@@ -35,7 +85,11 @@ class SharedViewSet(FederationMixin, viewsets.GenericViewSet):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
@action(methods=["post"], detail=False)
@action(
methods=["post"],
detail=False,
content_negotiation_class=renderers.IgnoreClientContentNegotiation,
)
def inbox(self, request, *args, **kwargs):
if request.method.lower() == "post" and request.actor is None:
raise exceptions.AuthenticationFailed(
......@@ -50,34 +104,187 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
lookup_field = "preferred_username"
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = models.Actor.objects.local().select_related("user")
queryset = (
models.Actor.objects.local()
.select_related("user", "channel__artist", "channel__attributed_to")
.prefetch_related("channel__artist__tagged_items__tag")
)
serializer_class = serializers.ActorSerializer
@action(methods=["get", "post"], detail=True)
def get_queryset(self):
queryset = super().get_queryset()
return queryset.exclude(channel__attributed_to=actors.get_service_actor())
def get_permissions(self):
# cf #1999 it must be possible to fetch actors without being authenticated
# otherwise we end up in a loop
if self.action == "retrieve":
return []
return super().get_permissions()
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
if instance.get_channel():
return redirect_to_html(instance.channel.get_absolute_url())
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
@action(
methods=["get", "post"],
detail=True,
content_negotiation_class=renderers.IgnoreClientContentNegotiation,
)
def inbox(self, request, *args, **kwargs):
inbox_actor = self.get_object()
if request.method.lower() == "post" and request.actor is None:
raise exceptions.AuthenticationFailed(
"You need a valid signature to send an activity"
)
if request.method.lower() == "post":
activity.receive(activity=request.data, on_behalf_of=request.actor)
activity.receive(
activity=request.data,
on_behalf_of=request.actor,
inbox_actor=inbox_actor,
)
return response.Response({}, status=200)
@action(methods=["get", "post"], detail=True)
def outbox(self, request, *args, **kwargs):
actor = self.get_object()
channel = actor.get_channel()
if channel:
return self.get_channel_outbox_response(request, channel)
return response.Response({}, status=200)
@action(methods=["get"], detail=True)
def get_channel_outbox_response(self, request, channel):
conf = {
"id": channel.actor.outbox_url,
"actor": channel.actor,
"items": channel.library.uploads.for_federation()
.order_by("-creation_date")
.prefetch_related(
"library__channel__actor", "track__artist_credit__artist"
),
"item_serializer": serializers.ChannelCreateUploadSerializer,
}
return get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.ChannelOutboxSerializer(channel),
)
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def followers(self, request, *args, **kwargs):
self.get_object()
# XXX to implement
return response.Response({})
actor = self.get_object()
followers = list(actor.get_approved_followers())
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-followers",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": followers,
"item_serializer": serializers.ActorSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
@action(methods=["get"], detail=True)
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def following(self, request, *args, **kwargs):
self.get_object()
# XXX to implement
return response.Response({})
actor = self.get_object()
followings = list(
actor.emitted_follows.filter(approved=True).values_list("target", flat=True)
)
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-following",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": followings,
"item_serializer": serializers.ActorSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def listens(self, request, *args, **kwargs):
actor = self.get_object()
listenings = history_models.Listening.objects.filter(actor=actor)
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-listens",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": listenings,
"item_serializer": serializers.ListeningSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def likes(self, request, *args, **kwargs):
actor = self.get_object()
likes = favorites_models.TrackFavorite.objects.filter(actor=actor)
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-likes",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": likes,
"item_serializer": serializers.TrackFavoriteSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
......@@ -104,8 +311,6 @@ class WellKnownViewSet(viewsets.GenericViewSet):
@action(methods=["get"], detail=False)
def nodeinfo(self, request, *args, **kwargs):
if not preferences.get("instance__nodeinfo_enabled"):
return HttpResponse(status=404)
data = {
"links": [
{
......@@ -122,9 +327,9 @@ class WellKnownViewSet(viewsets.GenericViewSet):
return HttpResponse(status=405)
try:
resource_type, resource = webfinger.clean_resource(request.GET["resource"])
cleaner = getattr(webfinger, "clean_{}".format(resource_type))
cleaner = getattr(webfinger, f"clean_{resource_type}")
result = cleaner(resource)
handler = getattr(self, "handler_{}".format(resource_type))
handler = getattr(self, f"handler_{resource_type}")
data = handler(result)
except forms.ValidationError as e:
return response.Response({"errors": {"resource": e.message}}, status=400)
......@@ -160,70 +365,79 @@ def has_library_access(request, library):
return library.received_follows.filter(actor=actor, approved=True).exists()
def has_playlist_access(request, playlist):
if playlist.privacy_level == "everyone":
return True
if request.user.is_authenticated and request.user.is_superuser:
return True
try:
actor = request.actor
except AttributeError:
return False
return playlist.library.received_follows.filter(actor=actor, approved=True).exists()
class MusicLibraryViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
serializer_class = serializers.LibrarySerializer
queryset = music_models.Library.objects.all().select_related("actor")
queryset = (
music_models.Library.objects.all()
.local()
.select_related("actor")
.filter(channel=None)
)
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
lb = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(lb.get_absolute_url())
items_qs = (
lb.uploads.for_federation()
if not lb.playlist_uploads.all()
else lb.playlist_uploads.for_federation()
)
conf = {
"id": lb.get_federation_id(),
"actor": lb.actor,
"name": lb.name,
"summary": lb.description,
"items": lb.uploads.for_federation()
.order_by("-creation_date")
.prefetch_related(
"items": items_qs.order_by("-creation_date").prefetch_related(
Prefetch(
"track",
queryset=music_models.Track.objects.select_related(
"album__artist__attributed_to",
"artist__attributed_to",
"attachment_cover",
"album__attributed_to",
"attributed_to",
"album__attachment_cover",
"description",
).prefetch_related(
"album__artist_credit__artist__attributed_to",
"artist_credit__artist__attributed_to",
"artist_credit__artist__attachment_cover",
"tagged_items__tag",
"album__tagged_items__tag",
"album__artist__tagged_items__tag",
"artist__tagged_items__tag",
"album__artist_credit__artist__tagged_items__tag",
"album__artist_credit__artist__attachment_cover",
"artist_credit__artist__tagged_items__tag",
"artist_credit__artist__description",
"album__description",
),
)
),
"item_serializer": serializers.UploadSerializer,
"library": lb,
}
page = request.GET.get("page")
if page is None:
serializer = serializers.LibrarySerializer(lb)
data = serializer.data
else:
# if actor is requesting a specific page, we ensure library is public
# or readable by the actor
if not has_library_access(request, lb):
raise exceptions.AuthenticationFailed(
"You do not have access to this library"
return get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.LibrarySerializer(lb),
page_access_check=lambda: has_library_access(request, lb),
)
try:
page_number = int(page)
except Exception:
return response.Response({"page": ["Invalid page number"]}, status=400)
conf["page_size"] = preferences.get("federation__collection_page_size")
p = paginator.Paginator(conf["items"], conf["page_size"])
try:
page = p.page(page_number)
conf["page"] = page
serializer = serializers.CollectionPageSerializer(conf)
data = serializer.data
except paginator.EmptyPage:
return response.Response(status=404)
return response.Response(data)
@action(methods=["get"], detail=True)
def followers(self, request, *args, **kwargs):
......@@ -237,48 +451,324 @@ class MusicUploadViewSet(
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Upload.objects.local().select_related(
queryset = (
music_models.Upload.objects.local()
.select_related(
"library__actor",
"track__artist",
"track__album__artist",
"track__description",
"track__album__attachment_cover",
"track__attachment_cover",
)
.prefetch_related(
"track__artist_credit__artist",
"track__album__artist_credit__artist",
"track__album__artist_credit__artist__attachment_cover",
"track__artist_credit__artist__attachment_cover",
)
)
serializer_class = serializers.UploadSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.track.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
def get_queryset(self):
queryset = super().get_queryset()
actor = music_utils.get_actor_from_request(self.request)
return queryset.playable_by(actor)
def get_serializer(self, obj):
if obj.library.get_channel():
return serializers.ChannelUploadSerializer(obj)
return super().get_serializer(obj)
@action(
methods=["get"],
detail=True,
content_negotiation_class=renderers.IgnoreClientContentNegotiation,
)
def activity(self, request, *args, **kwargs):
object = self.get_object()
serializer = serializers.ChannelCreateUploadSerializer(object)
return response.Response(serializer.data)
class MusicArtistViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Artist.objects.local()
queryset = music_models.Artist.objects.local().select_related(
"description", "attachment_cover"
)
serializer_class = serializers.ArtistSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class MusicArtistCreditViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.ArtistCredit.objects.local().prefetch_related("artist")
serializer_class = serializers.ArtistCreditSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class MusicAlbumViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Album.objects.local().select_related("artist")
queryset = (
music_models.Album.objects.local()
.prefetch_related(
"artist_credit__artist__description",
"artist_credit__artist__attachment_cover",
)
.select_related(
"description",
)
)
serializer_class = serializers.AlbumSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class MusicTrackViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Track.objects.local().select_related(
"album__artist", "artist"
queryset = (
music_models.Track.objects.local()
.select_related(
"album__description",
"description",
"attachment_cover",
"album__attachment_cover",
)
.prefetch_related(
"album__artist_credit__artist",
"artist_credit__artist__description",
"artist_credit__artist__attachment_cover",
"album__artist_credit__artist__attachment_cover",
)
)
serializer_class = serializers.TrackSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class ChannelViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Artist.objects.local().select_related(
"description", "attachment_cover"
)
serializer_class = serializers.ArtistSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class IndexViewSet(FederationMixin, viewsets.GenericViewSet):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
def dispatch(self, request, *args, **kwargs):
if not preferences.get("federation__public_index"):
return HttpResponse(status=405)
return super().dispatch(request, *args, **kwargs)
@action(
methods=["get"],
detail=False,
)
def libraries(self, request, *args, **kwargs):
libraries = (
music_models.Library.objects.local()
.filter(channel=None, privacy_level="everyone")
.prefetch_related("actor")
.order_by("creation_date")
)
conf = {
"id": federation_utils.full_url(
reverse("federation:index:index-libraries")
),
"items": libraries,
"item_serializer": serializers.LibrarySerializer,
"page_size": 100,
"actor": None,
}
return get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response.Response({}, status=200)
@action(
methods=["get"],
detail=False,
)
def channels(self, request, *args, **kwargs):
actors = (
models.Actor.objects.local()
.exclude(channel=None)
.order_by("channel__creation_date")
.prefetch_related(
"channel__attributed_to",
"channel__artist",
"channel__artist__description",
"channel__artist__attachment_cover",
)
)
conf = {
"id": federation_utils.full_url(reverse("federation:index:index-channels")),
"items": actors,
"item_serializer": serializers.ActorSerializer,
"page_size": 100,
"actor": None,
}
return get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response.Response({}, status=200)
class TrackFavoriteViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [common_permissions.PrivacyLevelPermission]
renderer_classes = renderers.get_ap_renderers()
queryset = favorites_models.TrackFavorite.objects.local().select_related(
"track", "actor"
)
serializer_class = serializers.TrackFavoriteSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class ListeningsViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [common_permissions.PrivacyLevelPermission]
renderer_classes = renderers.get_ap_renderers()
queryset = history_models.Listening.objects.local().select_related("track", "actor")
serializer_class = serializers.ListeningSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class PlaylistViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = playlists_models.Playlist.objects.local().select_related("actor")
serializer_class = serializers.PlaylistCollectionSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
playlist = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(playlist.get_absolute_url())
conf = {
"id": playlist.fid,
"actor": playlist.actor,
"name": playlist.name,
"items": playlist.playlist_tracks.order_by("index").prefetch_related(
"track",
),
"item_serializer": serializers.PlaylistTrackSerializer,
"library": playlist.library.fid,
}
return get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.PlaylistCollectionSerializer(playlist),
page_access_check=lambda: has_playlist_access(request, playlist),
)
class PlaylistTrackViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = playlists_models.PlaylistTrack.objects.local().select_related("actor")
serializer_class = serializers.PlaylistTrackSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
plt = self.get_object()
if not has_playlist_access(request, plt.playlist):
return response.Response(status=403)
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(plt.get_absolute_url())
serializer = self.get_serializer(plt)
return response.Response(serializer.data)
......@@ -30,7 +30,7 @@ def clean_acct(acct_string, ensure_local=True):
raise forms.ValidationError("Invalid format")
if ensure_local and hostname.lower() != settings.FEDERATION_HOSTNAME:
raise forms.ValidationError("Invalid hostname {}".format(hostname))
raise forms.ValidationError(f"Invalid hostname {hostname}")
return username, hostname
......@@ -46,3 +46,12 @@ def get_resource(resource_string):
serializer = serializers.ActorWebfingerSerializer(data=response.json())
serializer.is_valid(raise_exception=True)
return serializer.validated_data
def get_ap_url(links):
for link in links:
if (
link.get("rel") == "self"
and link.get("type") == "application/activity+json"
):
return link["href"]
......@@ -8,7 +8,7 @@ record.registry.register_serializer(serializers.ListeningActivitySerializer)
@record.registry.register_consumer("history.Listening")
def broadcast_listening_to_instance_activity(data, obj):
if obj.user.privacy_level not in ["instance", "everyone"]:
if obj.actor.user.privacy_level not in ["instance", "everyone"]:
return
channels.group_send(
......
......@@ -5,6 +5,6 @@ from . import models
@admin.register(models.Listening)
class ListeningAdmin(admin.ModelAdmin):
list_display = ["track", "creation_date", "user", "session_key"]
search_fields = ["track__name", "user__username"]
list_select_related = ["user", "track"]
list_display = ["track", "creation_date", "actor", "session_key"]
search_fields = ["track__name", "actor__user__username"]
list_select_related = ["actor", "track"]
import factory
from django.conf import settings
from funkwhale_api.factories import registry, NoUpdateOnCreate
from funkwhale_api.factories import NoUpdateOnCreate, registry
from funkwhale_api.federation import models
from funkwhale_api.federation.factories import ActorFactory
from funkwhale_api.music import factories
from funkwhale_api.users.factories import UserFactory
@registry.register
class ListeningFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory)
actor = factory.SubFactory(ActorFactory)
track = factory.SubFactory(factories.TrackFactory)
fid = factory.Faker("federation_url")
uuid = factory.Faker("uuid4")
privacy_level = "everyone"
class Meta:
model = "history.Listening"
@factory.post_generation
def local(self, create, extracted, **kwargs):
if not extracted and not kwargs:
return
domain = models.Domain.objects.get_or_create(name=settings.FEDERATION_HOSTNAME)[
0
]
self.fid = f"https://{domain}/federation/music/favorite/{self.uuid}"
self.save(update_fields=["fid"])
......@@ -7,13 +7,13 @@ from . import models
class ListeningFilter(moderation_filters.HiddenContentFilterSet):
username = django_filters.CharFilter("user__username")
domain = django_filters.CharFilter("user__actor__domain_id")
scope = common_filters.ActorScopeFilter(actor_field="user__actor", distinct=True)
username = django_filters.CharFilter("actor__user__username")
domain = django_filters.CharFilter("actor__domain_id")
scope = common_filters.ActorScopeFilter(actor_field="actor", distinct=True)
class Meta:
model = models.Listening
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG[
"LISTENING"
]
fields = ["hidden", "scope"]
fields = []
# Generated by Django 3.2.20 on 2023-12-09 14:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('history', '0002_auto_20180325_1433'),
]
operations = [
migrations.AddField(
model_name='listening',
name='source',
field=models.CharField(blank=True, max_length=100, null=True),
),
]
import uuid
from django.db import migrations, models
from django.urls import reverse
from funkwhale_api.federation import utils
import django.db.models.deletion
def get_user_actor(apps, schema_editor):
MyModel = apps.get_model("history", "Listening")
for row in MyModel.objects.all():
actor = row.user.actor
row.actor = actor
row.save(update_fields=["actor"])
def gen_uuid(apps, schema_editor):
MyModel = apps.get_model("history", "Listening")
for row in MyModel.objects.all():
unique_uuid = uuid.uuid4()
while MyModel.objects.filter(uuid=unique_uuid).exists():
unique_uuid = uuid.uuid4()
fid = utils.full_url(
reverse("federation:music:listenings-detail", kwargs={"uuid": unique_uuid})
)
row.uuid = unique_uuid
row.fid = fid
row.save(update_fields=["uuid", "fid"])
def get_user_actor(apps, schema_editor):
MyModel = apps.get_model("history", "Listening")
for row in MyModel.objects.all():
actor = row.user.actor
row.actor = actor
row.save(update_fields=["actor"])
class Migration(migrations.Migration):
dependencies = [
("history", "0003_listening_source"),
("federation", "0028_auto_20221027_1141"),
]
operations = [
migrations.AddField(
model_name="listening",
name="actor",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="listenings",
to="federation.actor",
),
),
migrations.AddField(
model_name="listening",
name="fid",
field=models.URLField(
max_length=500,
null=True,
),
),
migrations.AddField(
model_name="listening",
name="url",
field=models.URLField(blank=True, max_length=500, null=True),
),
migrations.AddField(
model_name="listening",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, null=True),
),
migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="listening",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, unique=True),
),
migrations.AlterField(
model_name="listening",
name="fid",
field=models.URLField(
unique=True,
db_index=True,
max_length=500,
),
),
migrations.RunPython(get_user_actor, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name="listening",
name="user",
),
migrations.AlterField(
model_name="listening",
name="actor",
field=models.ForeignKey(
blank=False,
null=False,
on_delete=django.db.models.deletion.CASCADE,
related_name="listenings",
to="federation.actor",
),
),
]